Compare commits

..
Author SHA1 Message Date
liuyihe fbb7d4fe24 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-27 17:45:56 +08:00
liuyihe 5c162bf6c5 Merge origin/release/test and combine transport photo workflows 2026-10-27 17:45:43 +08:00
ouyangxq 8451607f6c 优化 2026-10-27 17:44:49 +08:00
liuyihe c148912b73 优化 2026-10-27 17:39:19 +08:00
ouyangxq 3464f35a01 fix(admin): 完善船期日历刷新与交互状态 2026-10-27 16:56:20 +08:00
ouyangxq b3ab87f22c style(finance): unify list and detail layouts and receipt controls 2026-10-27 16:55:34 +08:00
ouyangxq b9b930936d fix(finance): show payable settlement progress in sources 2026-10-27 12:08:56 +08:00
ouyangxq 392f94e95c fix(dispatch): guard unsaved evidence without disabling uploads 2026-10-27 10:17:05 +08:00
ouyangxq 4595dfef34 fix(dispatch): present loading evidence as a single-column list 2026-10-27 08:33:01 +08:00
ouyangxq 0137fd3a4c fix(dispatch): reject stale loading document upload rounds 2026-10-26 19:21:13 +08:00
ouyangxq a9dc19a86b 优化 2026-10-26 18:52:21 +08:00
ouyangxq 22242701e4 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-26 17:27:28 +08:00
ouyangxq ca7095af4c 优化 2026-10-26 17:27:27 +08:00
admin 04b099249e Merge pull request '同步排车档案照片与装车多图至 TEST' (#30) from hotfix-gray-bugfix into release/test 2026-10-26 17:26:12 +08:00
ouyangxq 48d6c31397 test(admin): isolate merged inbound fees and shortage components 2026-10-26 17:23:38 +08:00
ouyangxq b06d46b0b3 merge: integrate TEST changes with fleet photo updates 2026-10-26 17:21:14 +08:00
admin 06d814e82f Merge pull request 'feat(admin): 同步档案照片带入与装车多图维护' (#29) from hotfix-gray-bugfix into main 2026-10-26 17:15:09 +08:00
ouyangxq acb90007de feat(admin): synchronize fleet photos and multi-image loading documents 2026-10-26 17:12:38 +08:00
admin e399f3f76f Merge pull request '隐藏送柜报关详情中的查询任务技术信息' (#28) from hotfix-gray-bugfix into main
Reviewed-on: #28
2026-10-26 17:01:14 +08:00
ouyangxq 4ee3b541ac fix(admin): hide domestic container query diagnostics 2026-10-26 16:59:20 +08:00
admin 0ef04804c1 Merge pull request '修复灰度货柜与海外仓界面交互' (#27) from hotfix-gray-bugfix into main 2026-10-26 16:12:35 +08:00
ouyangxq cc02f4d907 fix(admin): simplify gray release warehouse workflows 2026-10-26 15:51:07 +08:00
admin 33cb18594e Merge pull request 'fix(admin): 入库单增加短少确认操作' (#26) from fix/inbound-shortage-confirmation into main 2026-10-26 15:47:30 +08:00
ouyangxq b5d8ccdffe Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-26 13:58:54 +08:00
ouyangxq 6580853ad3 优化 2026-10-26 13:58:52 +08:00
liuyihe 32c6a0ad7b Merge origin/release/test and preserve procurement direction with request unlock 2026-10-26 13:51:37 +08:00
liuyihe 18a78e5508 费用优化 2026-10-26 13:43:08 +08:00
188 changed files with 6678 additions and 1255 deletions
+2 -2
View File
@@ -50,13 +50,13 @@ describe('adminNavigation', () => {
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配任务', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
{ title: '财务管理', children: ['海外费用明细', '客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
expect(visible).toHaveLength(13)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(58)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(59)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
})
+2 -1
View File
@@ -157,7 +157,7 @@ export const adminNavigation: AdminNavigationNode[] = [
icon: PrototypeCustomerServiceIcon,
children: [
{ key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' },
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true },
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list' },
],
},
{
@@ -165,6 +165,7 @@ export const adminNavigation: AdminNavigationNode[] = [
title: '财务管理',
icon: PrototypeFinanceIcon,
children: [
{ key: 'overseas-fees', title: '海外费用明细', icon: CircleDollarSign, path: '/overseas-business/fees', permission: 'platform:overseas-fee:list' },
{ key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list' },
{ key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list' },
{ key: 'finance-receivable-statistics', title: '应收款统计表', icon: CircleDollarSign, path: '/finance/receivable-statistics', permission: 'platform:finance:receivable-statistics:list' },
@@ -188,7 +188,7 @@ onMounted(load)
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="containerDialogTitle(editing ? '调整尾程费用' : '新增尾程费用', props)" width="min(920px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
<section class="density-dialog-section">
<el-form class="density-form" label-position="left">
<div class="workbench-evidence"><table class="density-table fee-editor-table" aria-label="尾程费用填写"><tbody>
<div class="workbench-evidence"><table class="density-table density-table--editable fee-editor-table" aria-label="尾程费用填写"><tbody>
<tr><td><el-form-item label-position="left" label-width="110px" label="客户" required><span v-if="editing">{{ customerName(form.customerId) || editing.customerName || '—' }}</span><el-select v-else v-model="form.customerId" :disabled="voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label-position="left" label-width="110px" label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
<tr><td :colspan="form.direction === 'PAYABLE' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item></td><td v-if="form.direction === 'PAYABLE'"><el-form-item label-position="left" label-width="110px" label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td></tr>
@@ -1,10 +1,10 @@
import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect } from 'element-plus'
import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect, ElUpload } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue'
import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading'
import { getContainer, getContainerOverview, linkContainerOceanBooking, getContainerClearance, uploadContainerClearance, uploadContainerDocument, completeContainerClearance } from '../api/platformContainerLoading'
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, scheduleTerminalDrayagePickup, selectTerminalDrayageFleet, recordTerminalPickup } from '../api/terminalDrayages'
import { listSuppliers } from '@/modules/suppliers'
@@ -59,7 +59,7 @@ function accessibleText(wrapper: { element: Element } | undefined) {
describe('ContainerDetailView empty return', () => {
it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => {
vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never)
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0, plannedDate: '2026-10-23', isDefault: true }])
const wrapper = await mountDetail()
const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent
expect(value).toBe('2026-10-23(默认推算)')
@@ -100,7 +100,8 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(recordDirectDelivery).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...row, version: 1, actualAt: '2026-10-16T01:00:00' })
const wrapper = await mountDetail()
expect(getDirectDeliveries).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false)
await (wrapper.vm as unknown as { openDirect(): Promise<void> }).openDirect(); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '登记本票')!.trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
@@ -123,7 +124,8 @@ describe('ContainerDetailView empty return', () => {
canAccess.mockImplementation(code => code !== 'platform:firstleg:terminal-drayage:direct-unload:confirm')
vi.mocked(getDirectDeliveries).mockResolvedValue([{ requirementKey: 'INBOUND:9', inboundCode: 'IN-TEST', orderNo: null, destinationName: '测试客户仓', packageCount: null, version: 1, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '签收人', evidenceNo: 'POD-001', remark: null }])
const wrapper = await mountDetail()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false)
await (wrapper.vm as unknown as { openDirect(): Promise<void> }).openDirect(); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '查看签收')!.trigger('click'); await flushPromises()
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('签收事实只读')
@@ -206,17 +208,20 @@ describe('ContainerDetailView empty return', () => {
granted.value = true
await flushPromises()
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('已确认离仓')
expect(wrapper.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
expect((wrapper.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
expect(wrapper.text()).not.toContain('展开可查看去重后的完整门禁')
expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓')
})
it('reloads authoritative departure state once on each newly opened domestic detail', async () => {
const first = await mountDetail('DOMESTIC')
expect(first.text()).toContain('已确认离仓')
expect(first.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
expect((first.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
first.unmount()
const reopened = await mountDetail('DOMESTIC')
expect(reopened.text()).toContain('已确认离仓')
expect(reopened.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
expect((reopened.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2)
})
@@ -437,3 +442,54 @@ describe('domestic container loading list and booking link', () => {
expect(routerPush).not.toHaveBeenCalled()
})
})
describe('destination document automatic upload', () => {
const uploaded = { id: 'doc-1', fileId: 'file-1', fileName: '资料.pdf', version: 1, completedAt: null }
beforeEach(() => {
canAccess.mockReset().mockReturnValue(true)
routeState.query = {}
vi.mocked(getContainer).mockReset().mockResolvedValue({ ...base, outboundConfirmedAt: '2026-10-20T08:00:00', allowedActions: ['MANAGE_DESTINATION_DOCUMENTS'] } as never)
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
vi.mocked(getContainerClearance).mockReset().mockResolvedValue(null as never)
vi.mocked(uploadContainerDocument).mockReset().mockResolvedValue(uploaded as never)
vi.mocked(uploadContainerClearance).mockReset().mockResolvedValue(uploaded as never)
vi.mocked(completeContainerClearance).mockReset()
})
it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('saves %s on selection without completing clearance', async type => {
const wrapper = await mountDetail()
await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type)
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())!
expect(dialog.props('showConfirm')).toBe(false)
const file = new File(['data'], '资料.pdf', { type: 'application/pdf' })
const select = dialog.findComponent(ElUpload).props('onChange')!
select({ raw: file } as never, [])
select({ raw: file } as never, [])
await flushPromises()
const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance
expect(upload).toHaveBeenCalledTimes(1)
expect(upload).toHaveBeenCalledWith(...(type === 'DELIVERY_ORDER' ? ['9001', type, file, 0, expect.any(String)] : ['9001', file, 0, expect.any(String)]))
expect(completeContainerClearance).not.toHaveBeenCalled()
expect(dialog.props('modelValue')).toBe(false)
})
it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('retains a failed %s upload and retries the same file', async type => {
const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance
vi.mocked(upload).mockRejectedValueOnce(new Error('网络中断'))
const wrapper = await mountDetail()
await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type)
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())!
const file = new File(['data'], '资料.pdf', { type: 'application/pdf' })
dialog.findComponent(ElUpload).props('onChange')!({ raw: file } as never, [])
await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.text()).toContain('上传失败')
await dialog.findAll('button').find(button => button.text() === '重试上传')!.trigger('click')
await flushPromises()
expect(upload).toHaveBeenCalledTimes(2)
expect(vi.mocked(upload).mock.calls[1]).toEqual(vi.mocked(upload).mock.calls[0])
expect(dialog.props('modelValue')).toBe(false)
expect(completeContainerClearance).not.toHaveBeenCalled()
})
})
@@ -19,10 +19,8 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists'
import {
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
type ContainerOutboundDispatchDetail,
type ContainerOutboundGateItem,
} from '@/modules/outbound-dispatch'
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
@@ -244,7 +242,6 @@ async function saveDirect() {
}
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const canViewCurrentLoadingList = computed(() => {
@@ -274,6 +271,7 @@ const pickupCodeSaving = ref(false)
const clearanceOpen = ref(false)
const clearanceDocument = ref<BusinessDocument | null>(null)
const clearanceFile = ref<File | null>(null)
const clearanceUploadKey = ref(0)
const clearanceLoading = ref(false)
const clearanceSaving = ref(false)
const clearanceError = ref<ApiErrorDetails | null>(null)
@@ -282,10 +280,8 @@ const exceptionReason = ref('')
const exceptionSaving = ref(false)
const exceptionError = ref<ApiErrorDetails | null>(null)
const outboundLoading = ref(false)
const outboundConfirming = ref(false)
const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
const outboundError = ref<ApiErrorDetails | null>(null)
const showPassedGates = ref(false)
const carrierRefreshKey = ref(0)
const { detail: loadingDetail, loading: loadingDetailLoading, error: loadingDetailError, load: loadListDetail, reset: resetLoadingDetail } = useLoadingListDetail()
const emptyReturnOpen = ref(false)
@@ -337,32 +333,6 @@ const emptyReturnWarningText = computed(() => {
return `${overdue}${warning.deadlineAt ? ` · 截止 ${formatTime(warning.deadlineAt)}` : ''}`
})
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
const referenceOnlyGateCodes = new Set(['TRUCK_DEPARTED', 'SAILING_SCHEDULE'])
const outboundGateRows = computed<AggregatedGateRow[]>(() => {
const grouped = new Map<string, AggregatedGateRow>()
for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) {
for (const item of inbound.gate.items || []) {
if (referenceOnlyGateCodes.has(item.code)) continue
const key = JSON.stringify([item.code, item.passed, item.actual ?? null, item.requirement ?? null, item.name])
const existing = grouped.get(key)
if (existing) {
if (!existing.inboundCodes.includes(inbound.inboundCode)) existing.inboundCodes.push(inbound.inboundCode)
} else grouped.set(key, { ...item, key, inboundCodes: [inbound.inboundCode] })
}
}
return [...grouped.values()]
})
const visibleOutboundGateRows = computed(() => showPassedGates.value ? outboundGateRows.value : outboundGateRows.value.filter((row) => !row.passed))
const outboundGateSummary = computed(() => ({
total: outboundGateRows.value.length,
passed: outboundGateRows.value.filter((row) => row.passed).length,
blocked: outboundGateRows.value.filter((row) => !row.passed).length,
}))
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
&& hasAction('CONFIRM_OUTBOUND')
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
&& outboundGateSummary.value.blocked === 0)
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN'>(() =>
!outboundDetail.value ? 'UNKNOWN'
: outboundDetail.value.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
@@ -469,7 +439,7 @@ watch(
async function loadOutboundDispatch() {
if (!containerId.value || !canViewOutboundDispatch.value) return
outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false
outboundLoading.value = true; outboundError.value = null
try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) }
catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
finally { outboundLoading.value = false }
@@ -488,37 +458,6 @@ async function refreshAfterCustoms() {
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 报关操作已成功,保留组件返回事实 */ }
}
async function confirmDeparture() {
const aggregate = outboundDetail.value?.aggregate
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
outboundConfirming.value = true
try {
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', containerDialogTitle('确认国内出仓', detail.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
} catch { outboundConfirming.value = false; return }
outboundError.value = null
try {
outboundDetail.value = await confirmContainerOutboundDispatch(containerId.value, {
expectedVersion: aggregate.version,
remark: '货柜详情确认国内出仓',
})
carrierRefreshKey.value += 1
ElMessage.success('整柜国内出仓已确认,船节点查询任务正在刷新')
try { overview.value = await getContainerOverview(containerId.value) }
catch (cause) { outboundError.value = getApiErrorDetails(cause, '离仓已确认,但货柜履约摘要刷新失败') }
} catch (cause) {
const details = getApiErrorDetails(cause, '确认国内出仓失败')
if (details.code === 'A0409') await loadOutboundDispatch()
outboundError.value = details
} finally { outboundConfirming.value = false }
}
function outboundStatusText(status?: string | null) {
return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record<string, string>)[status || ''] || status || '—'
}
function truckReferenceText(status?: string | null) {
return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record<string, string>)[status || ''] || status || '未关联'
}
function openDocument(type: string) {
if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return }
if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return
@@ -526,9 +465,14 @@ function openDocument(type: string) {
documentType.value = type as UploadDocumentType
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
}
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
function selectDocumentFile(file: UploadFile) {
if (documentSaving.value) return
documentFile.value = file.raw || null; documentError.value = null
if (documentFile.value) void saveDocument()
}
function removeDocumentFile() { documentFile.value = null }
async function saveDocument() {
if (documentSaving.value || !canManageDestinationDocuments.value) return
if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
try {
@@ -601,14 +545,20 @@ async function saveException() {
}
async function openClearance() {
clearanceUploadKey.value += 1
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; documentRefreshWarning.value = null; clearanceLoading.value = true
try { clearanceDocument.value = await getContainerClearance(containerId.value) }
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
finally { clearanceLoading.value = false }
}
function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null }
function selectClearanceFile(file: UploadFile) {
if (clearanceSaving.value || clearanceLoading.value) return
clearanceFile.value = file.raw || null; clearanceError.value = null
if (clearanceFile.value) void uploadClearance()
}
function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() {
if (clearanceSaving.value || clearanceLoading.value || !canManageDestinationDocuments.value || clearanceDocument.value?.completedAt) return
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return }
clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null
try {
@@ -697,7 +647,7 @@ onMounted(async () => {
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-voyage-heading">
<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2><ContainerPortSchedulePanel :container="detail" @saved="load" /></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :hide-technical-details="isOverseas" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense hide-technical-details :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-customs-heading">
@@ -710,26 +660,10 @@ onMounted(async () => {
<section v-if="isDomestic" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-receiving-heading">
<h2 id="container-receiving-heading">装柜作业与国内出仓</h2>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
<header class="outbound-panel__header"><div><h2>国内出仓门禁<InlineHelp title="离仓核验规则"><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></InlineHelp></h2></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
<template v-else-if="outboundDetail">
<div class="gate-summary"><span>总门禁 <strong>{{ outboundGateSummary.total }}</strong></span><span class="gate-summary__passed">已通过 <strong>{{ outboundGateSummary.passed }}</strong></span><span :class="{ 'gate-summary__blocked': outboundGateSummary.blocked }">阻断 <strong>{{ outboundGateSummary.blocked }}</strong></span><span class="gate-summary__reference">拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}</span><el-button v-if="outboundGateSummary.passed" link type="primary" @click="showPassedGates = !showPassedGates">{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}</el-button></div>
<div class="gate-list">
<article v-for="row in visibleOutboundGateRows" :key="row.key" class="gate-row">
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span class="gate-row__actual">{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
<div class="gate-row__coverage"><span>{{ row.passed ? '覆盖' : '影响' }} {{ row.inboundCodes.length }} 个入库码</span><el-tag v-for="code in row.inboundCodes.slice(0, 3)" :key="code" size="small" effect="plain">{{ code }}</el-tag><span v-if="row.inboundCodes.length > 3">+{{ row.inboundCodes.length - 3 }}</span></div>
</article>
</div>
</template>
</div>
</section>
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading">
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canReadDirect" @click="openDirect">直送卸货清单</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section>
@@ -753,9 +687,9 @@ onMounted(async () => {
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" :show-confirm="!!documentError && !!documentFile" confirm-text="重试上传" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :disabled="documentSaving" :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button :loading="documentSaving"><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="!clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading || !clearanceFile" @click="uploadClearance">上传资料</el-button></div></template></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="false" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :key="clearanceUploadKey" :disabled="clearanceSaving || clearanceLoading" :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button :loading="clearanceSaving"><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="clearanceError && clearanceFile && !clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading" @click="uploadClearance">重试上传</el-button></div></template></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
@@ -94,7 +94,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
<template>
<section ref="dashboardRef" class="dashboard-view admin-workbench">
<AdminPage as="div" spacing="normal" class="admin-density admin-workbench">
<AdminPage as="div" spacing="compact" class="admin-density admin-workbench">
<section class="dashboard-context" aria-label="数据看板操作">
<p v-if="!hasMetrics || metricsError" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p>
<div class="dashboard-head-actions">
@@ -128,6 +128,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
<style scoped>
.dashboard-view { display: block; min-width: 0; }
.dashboard-view > .admin-page.is-spacing-compact { gap: var(--admin-space-2); }
.dashboard-view:fullscreen { overflow: auto; background: var(--workbench-surface); }
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--workbench-section-gap); }
.dashboard-head-actions { display: flex; margin-left: auto; align-items: center; gap: 12px; white-space: nowrap; }
@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createRequire } from 'node:module'
import DomesticContainersView from './DomesticContainersView.vue'
import DataTable from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import * as api from '../api/domesticContainers'
import type { InventoryRecord } from '../types'
import type { InventoryPage, InventoryRecord } from '../types'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
// Use Node's supported require export so the validator retains its constructor interop.
@@ -31,11 +34,16 @@ describe('DomesticContainersView', () => {
})
it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") })
it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') })
it('keeps all four inventory facts in the approved compact summary', () => {
for (const fact of ['summary.inboundBoxes', 'summary.outboundBoxes', 'summary.availableBoxes', 'summary.inboundVolumeCbm']) expect(source).toContain(fact)
it('keeps all four inventory facts and units in shared compact metric cards', () => {
for (const fact of ['summary?.inboundBoxes', 'summary?.outboundBoxes', 'summary?.availableBoxes', 'summary?.inboundVolumeCbm']) expect(source).toContain(fact)
expect(source).toContain("import './domesticContainersSummary.css'")
expect(summaryStyles).toContain('display: flex; flex-wrap: wrap')
expect(summaryStyles).toContain('font-size: var(--admin-font-section, 14px)')
const summary = source.match(/<section\b[^>]*class="inventory-summary"[\s\S]*?<\/section>/)?.[0] || ''
expect(summary).not.toContain('v-if=')
expect(summary.match(/class="workbench-metric workbench-tone is-compact"/g)).toHaveLength(4)
expect(summary.match(/<span>箱<\/span>/g)).toHaveLength(3)
expect(summary).toContain("summary?.inboundVolumeCbm.toFixed(2) ?? '—' }}</strong><span>m³</span>")
expect(summaryStyles).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
expect(summaryStyles).toContain('gap: var(--workbench-grid-gap)')
expect(summaryStyles).not.toContain('position: absolute')
})
it('does not offer an inventory detail action before an inbound has formed inventory', () => { expect(source).toContain("canViewInventory(row as ContainerOrder | InventoryRecord)"); expect(source).toContain("row.status === 'COMPLETED'") })
@@ -49,10 +57,14 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mock
vi.mock('../api/domesticContainers', () => ({ createDomesticContainerInbound: vi.fn(), createDomesticContainerOutbound: vi.fn(), getDomesticContainerInventory: vi.fn(), listDomesticContainerInbounds: vi.fn(), listDomesticContainerInventories: vi.fn(), listDomesticContainerOutbounds: vi.fn() }))
const inventory: InventoryRecord = { id: 'inventory-1', warehouseId: 'warehouse-1', warehouseName: '测试仓', containerNo: 'MSCU1234567', cargoName: '测试货物', availableBoxes: 8, inboundBoxes: 10, outboundBoxes: 2, inboundVolumeCbm: 2.5, inventoryStatus: 'PARTIALLY_OUTBOUND', version: 3, updatedAt: '2026-09-30T10:00:00' }
let mounted: VueWrapper | undefined
async function openCreate(label: string) {
async function mountView() {
const view = mount(DomesticContainersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
mounted = view
await flushPromises()
return view
}
async function openCreate(label: string) {
const view = await mountView()
if (label === '新建出库单') await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click')
await flushPromises()
await view.findAll('button').find(button => button.text() === label)!.trigger('click')
@@ -64,7 +76,7 @@ async function submitCreate() {
document.querySelector<HTMLButtonElement>('.el-dialog__footer .el-button--primary')!.click()
await flushPromises()
}
describe('DomesticContainersView create workflows', () => {
describe('DomesticContainersView workflows', () => {
beforeEach(() => {
vi.clearAllMocks()
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
@@ -75,6 +87,60 @@ describe('DomesticContainersView create workflows', () => {
})
afterEach(() => { mounted?.unmount(); mounted = undefined; document.body.innerHTML = '' })
it('loads the overview on entry and keeps it above the list across all three tabs', async () => {
const view = await mountView()
const overview = view.get('.inventory-summary')
expect(view.element.firstElementChild).toBe(overview.element)
expect(overview.findAll('strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
expect(api.listDomesticContainerInventories).toHaveBeenCalledTimes(1)
for (const label of ['整柜出库单', '整柜库存', '整柜入库单']) {
const requests = vi.mocked(api.listDomesticContainerInventories).mock.calls.length
await view.findAll('[role="tab"]').find(tab => tab.text() === label)!.trigger('click')
await flushPromises()
expect(view.element.firstElementChild).toBe(overview.element)
expect(overview.findAll('strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
expect(api.listDomesticContainerInventories).toHaveBeenCalledTimes(requests + 1)
}
})
it.each(['整柜入库单', '整柜出库单'])('uses common filters for the overview on %s without passing order-only filters', async label => {
const view = await mountView()
await view.findAll('[role="tab"]').find(tab => tab.text() === label)!.trigger('click')
await flushPromises()
const filters = { warehouseId: 'warehouse-1', containerNo: 'MSCU1234567', cargoName: '测试货物', orderNo: 'ORDER-01', status: 'PENDING' }
view.findComponent(QueryToolbar).vm.$emit('search', filters)
await flushPromises()
expect(api.listDomesticContainerInventories).toHaveBeenLastCalledWith({ page: 1, size: 1, warehouseId: filters.warehouseId, containerNo: filters.containerNo, cargoName: filters.cargoName })
const list = label === '整柜入库单' ? api.listDomesticContainerInbounds : api.listDomesticContainerOutbounds
expect(list).toHaveBeenLastCalledWith({ ...filters, page: 1, size: 20 })
})
it('shows unknown summary values on failure while keeping the order list available and allowing retry', async () => {
vi.mocked(api.listDomesticContainerInventories).mockRejectedValueOnce(new Error('统计失败'))
const view = await mountView()
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['—', '—', '—', '—'])
expect(view.findComponent(DataTable).props('resultsVisible')).toBe(true)
const error = view.findComponent(LoadErrorState)
expect(error.props('title')).toBe('整柜库存统计加载失败')
error.vm.$emit('retry')
await flushPromises()
expect(view.findComponent(LoadErrorState).exists()).toBe(false)
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
})
it('ignores a stale overview response after switching tabs', async () => {
let resolveFirst!: (value: InventoryPage) => void
vi.mocked(api.listDomesticContainerInventories).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
const view = await mountView()
expect(view.get('.inventory-summary').attributes('aria-busy')).toBe('true')
await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click')
await flushPromises()
resolveFirst({ records: [], page: 1, size: 1, total: 0, pages: 0, summary: { inboundBoxes: 99, outboundBoxes: 0, availableBoxes: 99, inboundVolumeCbm: 99 } })
await flushPromises()
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
expect(view.get('.inventory-summary').attributes('aria-busy')).toBe('false')
})
it('keeps validation, normalized input and the idempotency key when retrying an inbound', async () => {
vi.mocked(api.createDomesticContainerInbound).mockRejectedValueOnce(new Error('临时失败')).mockResolvedValueOnce({ id: 'order-internal', orderNo: 'ZGRK-01', replayed: false })
const { form } = await openCreate('新建入库单')
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Plus } from '@lucide/vue'
import { Box, Boxes, PackageCheck, PackagePlus, Plus } from '@lucide/vue'
import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -28,7 +28,10 @@ const businessTabs: Array<{ value: TabName; label: string }> = [
{ value: 'inventory', label: '整柜库存' },
]
const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const rows = ref<(ContainerOrder | InventoryRecord)[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const summary = ref<InventorySummary>({ inboundBoxes: 0, outboundBoxes: 0, availableBoxes: 0, inboundVolumeCbm: 0 })
const summary = ref<InventorySummary | null>(null)
const summaryLoading = ref(false)
const summaryError = ref<ApiErrorDetails | null>(null)
let loadSequence = 0
const warehouses = ref<PlatformWarehouseSummaryResource[]>([]); const warehouseLoading = ref(false); const warehouseError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ warehouseId: '', orderNo: '', containerNo: '', cargoName: '', status: '' })
const appliedFilters = ref<QueryModel>({ ...filters.value })
@@ -49,7 +52,41 @@ function displayTime(value: string) { return value ? value.replace('T', ' ').sli
function statusLabel(row: ContainerOrder | InventoryRecord) { if ('inventoryStatus' in row) return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as const)[row.inventoryStatus]; return row.status === 'PENDING' ? (activeTab.value === 'inbound' ? '待入库' : '待出库') : (activeTab.value === 'inbound' ? '已入库' : '已出库') }
function canViewInventory(row: ContainerOrder | InventoryRecord) { return 'inventoryStatus' in row || activeTab.value === 'outbound' || row.status === 'COMPLETED' }
function queryParams() { const model = appliedFilters.value; return { page: page.value, size: pageSize.value, warehouseId: String(model.warehouseId || '') || undefined, orderNo: activeTab.value === 'inventory' ? undefined : String(model.orderNo || '') || undefined, containerNo: String(model.containerNo || '') || undefined, cargoName: String(model.cargoName || '') || undefined, status: String(model.status || '') || undefined } }
async function load() { loading.value = true; loadError.value = null; try { const result = activeTab.value === 'inbound' ? await listDomesticContainerInbounds(queryParams()) : activeTab.value === 'outbound' ? await listDomesticContainerOutbounds(queryParams()) : await listDomesticContainerInventories(queryParams()); rows.value = result.records; total.value = result.total; if ('summary' in result) summary.value = result.summary } catch (cause) { loadError.value = getApiErrorDetails(cause, '整柜业务加载失败') } finally { loading.value = false } }
async function load() {
const sequence = ++loadSequence
const tab = activeTab.value
const params = queryParams()
loading.value = true
loadError.value = null
summaryLoading.value = true
summaryError.value = null
summary.value = null
const inventoryRequest = listDomesticContainerInventories(tab === 'inventory' ? params : {
page: 1,
size: 1,
warehouseId: params.warehouseId,
containerNo: params.containerNo,
cargoName: params.cargoName,
})
const summaryTask = inventoryRequest.then(result => {
if (sequence === loadSequence) summary.value = result.summary
}).catch(cause => {
if (sequence === loadSequence && tab !== 'inventory') summaryError.value = getApiErrorDetails(cause, '整柜库存统计加载失败')
}).finally(() => {
if (sequence === loadSequence) summaryLoading.value = false
})
const listRequest = tab === 'inbound' ? listDomesticContainerInbounds(params) : tab === 'outbound' ? listDomesticContainerOutbounds(params) : inventoryRequest
const listTask = listRequest.then(result => {
if (sequence !== loadSequence) return
rows.value = result.records
total.value = result.total
}).catch(cause => {
if (sequence === loadSequence) loadError.value = getApiErrorDetails(cause, '整柜业务加载失败')
}).finally(() => {
if (sequence === loadSequence) loading.value = false
})
await Promise.all([summaryTask, listTask])
}
async function loadWarehouses() { warehouseLoading.value = true; warehouseError.value = null; try { warehouses.value = (await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })).filter((item) => item.warehouseType === 'DOMESTIC' && item.status === 'ACTIVE') } catch (cause) { warehouseError.value = getApiErrorDetails(cause, '国内仓候选加载失败') } finally { warehouseLoading.value = false } }
function search(model = filters.value) { appliedFilters.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function switchTab(name: TabName) { if (activeTab.value === name) return; activeTab.value = name; filters.value = { warehouseId: filters.value.warehouseId || '', orderNo: '', containerNo: '', cargoName: '', status: '' }; appliedFilters.value = { ...filters.value }; page.value = 1; void load() }
@@ -73,13 +110,31 @@ async function submit() { if (!await formRef.value?.validate().catch(() => false
watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWarehouses(), load()]) })
</script>
<template><AdminPage as="div" class="domestic-containers-page admin-density admin-workbench">
<template><AdminPage as="div" spacing="compact" class="domestic-containers-page admin-density admin-workbench">
<section v-loading="summaryLoading" class="inventory-summary" aria-label="整柜库存概览" :aria-busy="summaryLoading">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackagePlus aria-hidden="true" /></span><h2 class="workbench-metric-label">入库箱数</h2></div>
<div class="workbench-metric-value"><strong>{{ summary?.inboundBoxes ?? '—' }}</strong><span>箱</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">已出库箱数</h2></div>
<div class="workbench-metric-value"><strong>{{ summary?.outboundBoxes ?? '—' }}</strong><span>箱</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Boxes aria-hidden="true" /></span><h2 class="workbench-metric-label">当前库存</h2></div>
<div class="workbench-metric-value"><strong>{{ summary?.availableBoxes ?? '—' }}</strong><span>箱</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="teal">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Box aria-hidden="true" /></span><h2 class="workbench-metric-label">累计入库体积</h2></div>
<div class="workbench-metric-value"><strong>{{ summary?.inboundVolumeCbm.toFixed(2) ?? '—' }}</strong><span>m³</span></div>
</article>
</section>
<LoadErrorState v-if="summaryError" compact title="整柜库存统计加载失败" :message="summaryError.message" :loading="summaryLoading" @retry="load"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-results" ><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'">
<template #toolbar-start><div class="status-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)">{{ tab.label }}</button></div></template>
<template #filters><div class="business-query-panel integrated-query-group"><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
<template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #before-results><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div></template>
<template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" class="density-action" size="small" @click="viewInventory(row as ContainerOrder | InventoryRecord)">查看</el-button><span v-else>—</span></div></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></div></section></ListPageLayout>
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog admin-workbench is-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit">
<LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState>
@@ -122,4 +177,7 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
</el-form>
</AppDialog></AdminPage></template>
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--workbench-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--workbench-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--workbench-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.container-entry-table{table-layout:fixed}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--workbench-border)}}@media(max-width:360px){.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--workbench-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
<style scoped>
.admin-workbench.domestic-containers-page.admin-page.is-spacing-compact { gap: 0; }
.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.container-entry-table{table-layout:fixed}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
</style>
@@ -1,9 +1,17 @@
.domestic-containers-page .table-panel .inventory-summary {
display: flex; flex-wrap: wrap; gap: var(--admin-space-2, 6px) 28px; margin: 0;
padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); border: 0; border-bottom: 1px solid var(--cc-border); background: var(--admin-surface, #fff);
.domestic-containers-page .inventory-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--workbench-grid-gap);
}
.domestic-containers-page .table-panel .inventory-summary > div {
display: flex; align-items: baseline; min-width: 0; gap: var(--admin-space-3, 8px); padding: 0; border: 0;
@media (max-width: 720px) {
.domestic-containers-page .inventory-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 360px) {
.domestic-containers-page .inventory-summary {
grid-template-columns: 1fr;
}
}
.domestic-containers-page .table-panel .inventory-summary span { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.domestic-containers-page .table-panel .inventory-summary strong { font-size: var(--admin-font-section, 14px); font-weight: 600; }
@@ -3,7 +3,7 @@ import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, ref, watch } from 'vue'
import { AlertTriangle, ArrowLeft, CircleCheck, Download, FileSpreadsheet, RefreshCw, Ship, Warehouse } from '@lucide/vue'
import { ArrowLeft, CircleCheck, Download, RefreshCw } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -448,12 +448,6 @@ onMounted(() => {
</div>
</div>
</template>
<template v-if="detail" #summary>
<dl class="density-detail-overview">
<div><dt>报价范围</dt><dd>{{ priceBookScopeLabel(detail.version.customerId) }}</dd></div>
<div><dt>上传时间</dt><dd>{{ formatDateTime(detail.version.createdAt) }}</dd></div>
</dl>
</template>
<LoadErrorState v-if="loadError" title="船期价格表详情加载失败" :message="loadError.message" :loading="loading" @retry="loadDetail">
<template #details><ApiErrorMeta :details="loadError" /></template>
@@ -462,9 +456,11 @@ onMounted(() => {
<template v-else-if="detail">
<section class="version-panel">
<header class="section-heading">
<div><h2><FileSpreadsheet :size="18" />版本信息</h2><p>{{ detail.version.originalFileName }}</p></div>
<div><h2>版本信息</h2></div>
</header>
<dl class="detail-grid density-fields">
<div class="detail-grid__half"><dt>报价范围</dt><dd>{{ priceBookScopeLabel(detail.version.customerId) }}</dd></div>
<div class="detail-grid__half"><dt>上传时间</dt><dd>{{ formatDateTime(detail.version.createdAt) }}</dd></div>
<div><dt>解析渠道</dt><dd>{{ detail.version.channelCount }}</dd></div>
<div><dt>解析价格行</dt><dd>{{ detail.version.rateCount }}</dd></div>
<div><dt>阻断错误</dt><dd :class="{ 'is-danger': detail.version.blockingErrorCount }">{{ detail.version.blockingErrorCount }}</dd></div>
@@ -475,7 +471,7 @@ onMounted(() => {
</section>
<section v-if="isPending && canEdit" class="mapping-section">
<header class="section-heading"><div><h2><Warehouse :size="18" />仓库映射<InlineHelp title="仓库映射规则"><p>来源编码必须匹配启用的平台公共仓库编码。</p></InlineHelp></h2></div></header>
<header class="section-heading"><div><h2>仓库映射<InlineHelp title="仓库映射规则"><p>来源编码必须匹配启用的平台公共仓库编码。</p></InlineHelp></h2></div></header>
<el-alert
v-if="nonMappingBlockingIssues.length"
title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
@@ -514,37 +510,39 @@ onMounted(() => {
</section>
<section class="detail-section">
<header class="section-heading"><div><h2><AlertTriangle :size="18" />解析与校验<InlineHelp title="价格表确认规则"><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></InlineHelp></h2></div></header>
<header class="section-heading"><div><h2>解析与校验<InlineHelp title="价格表确认规则"><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></InlineHelp></h2></div></header>
<el-empty v-if="!detail.issues.length" description="未发现解析问题" :image-size="72" />
<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">
<el-table-column label="级别" width="90"><template #default="{ row }"><el-tag :type="(row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? 'danger' : 'warning'" size="small">{{ (row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? '阻断' : '警告' }}</el-tag></template></el-table-column>
<el-table-column prop="issueCode" label="编码" min-width="165" show-overflow-tooltip />
<el-table-column prop="message" label="说明" min-width="250" show-overflow-tooltip />
<el-table-column prop="issueCode" label="编码" width="270" />
<el-table-column prop="message" label="说明" min-width="320" />
<el-table-column label="来源" min-width="180"><template #default="{ row }">{{ issueLocation(row as PlatformPriceBookParseIssue) }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section channel-preview">
<header class="section-heading"><div><h2><Ship :size="18" />按渠道预览</h2></div></header>
<header class="section-heading"><div><h2>按渠道预览</h2></div></header>
<el-empty v-if="!detail.channels.length" description="未解析出渠道" :image-size="72" />
<template v-else>
<el-tabs v-model="selectedChannelCode" class="channel-tabs density-element-tabs">
<el-tab-pane v-for="channel in detail.channels" :key="channel.channelCode" :name="channel.channelCode"><template #label><span class="density-tab-icon" aria-hidden="true">🚢</span>{{ channel.channelName }}</template></el-tab-pane>
<el-tab-pane v-for="channel in detail.channels" :key="channel.channelCode" :name="channel.channelCode"><template #label>{{ channel.channelName }}</template></el-tab-pane>
</el-tabs>
<div class="channel-meta"><span>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</span><span>表页:{{ selectedChannel?.sourceSheetName || '—' }}</span></div>
<dl class="channel-facts density-fields" aria-label="渠道信息">
<div><dt>渠道</dt><dd>{{ selectedChannel?.channelName || '—' }}</dd></div>
<div><dt>计价方式</dt><dd>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</dd></div>
<div><dt>来源表页</dt><dd>{{ selectedChannel?.sourceSheetName || '—' }}</dd></div>
<div><dt>参考时效</dt><dd>{{ selectedChannel?.referenceTransit || '—' }}</dd></div>
<div><dt>最晚进仓时间</dt><dd>{{ selectedChannel?.latestWarehouseEntry || '—' }}</dd></div>
<div><dt>船期</dt><dd>{{ selectedChannel?.estimatedDepartureText || selectedChannel?.estimatedDepartureDate || '—' }}</dd></div>
<div class="channel-facts__wide"><dt>附加费</dt><dd>{{ selectedChannel?.surchargeDescription || '—' }}</dd></div>
<div class="channel-facts__wide"><dt>备注</dt><dd>{{ selectedChannel?.remark || '—' }}</dd></div>
</dl>
<el-table :data="selectedChannelRates" size="small" max-height="440" border stripe aria-label="渠道价格行预览">
<el-table-column label="渠道" min-width="140" show-overflow-tooltip><template #default>{{ selectedChannel?.channelName || '—' }}</template></el-table-column>
<el-table-column prop="originWarehouseName" label="起运仓" min-width="130" show-overflow-tooltip />
<el-table-column prop="originWarehouseName" label="起运仓" min-width="180" />
<el-table-column label="目的地" min-width="140"><template #default="{ row }">{{ rateDestinationLabel(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
<el-table-column label="包税方式" width="105"><template #default="{ row }">{{ pricingTaxModeLabel((row as PlatformPriceBookParsedRate).pricingTaxMode) }}</template></el-table-column>
<el-table-column prop="chargeUnit" label="单位" width="78" />
<el-table-column label="单价" min-width="120"><template #default="{ row }">{{ formatMoney((row as PlatformPriceBookParsedRate).unitPrice, (row as PlatformPriceBookParsedRate).currency) }}</template></el-table-column>
<el-table-column label="参考时效" min-width="130" show-overflow-tooltip><template #default>{{ selectedChannel?.referenceTransit || '—' }}</template></el-table-column>
<el-table-column label="最晚进仓时间" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.latestWarehouseEntry || '—' }}</template></el-table-column>
<el-table-column label="船期" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.estimatedDepartureText || selectedChannel?.estimatedDepartureDate || '—' }}</template></el-table-column>
<el-table-column label="附加费" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.surchargeDescription || '—' }}</template></el-table-column>
<el-table-column label="备注" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.remark || '—' }}</template></el-table-column>
<el-table-column label="生效状态" width="105"><template #default>{{ priceBookStatusMeta(detail.version.status).label }}</template></el-table-column>
<el-table-column label="来源" min-width="170"><template #default="{ row }">{{ sourceLocation(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
</el-table>
</template>
@@ -555,23 +553,24 @@ onMounted(() => {
</template>
<style scoped>
.price-book-detail { min-width: 0; max-width: 1880px; }
.version-panel, .mapping-section, .detail-section { padding: var(--admin-space-5) 0; border-bottom: 1px solid var(--workbench-border); }
.price-book-detail { min-width: 0; }
.price-book-detail :deep(.density-detail-layout > .detail-page-layout__content) { padding: 0; }
.price-book-detail .channel-facts { grid-template-columns: repeat(3, minmax(0, 1fr)); --admin-field-label-width: 110px; }
.channel-facts .channel-facts__wide { grid-column: 1 / -1; }
.detail-grid dd, .channel-facts dd { overflow-wrap: anywhere; }
.version-panel, .mapping-section, .detail-section { display: grid; min-width: 0; gap: var(--admin-space-5); padding: var(--admin-space-5) 0; border-bottom: 1px solid var(--workbench-border); }
.detail-section:last-child { border-bottom: 0; }
.price-book-header-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.detail-grid .detail-grid__half { grid-column: span 2; }
.detail-grid .detail-grid__wide { grid-column: 1 / -1; }
.section-heading > div { min-width: 0; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-5); margin: 0; }
.section-heading h2 { display: flex; align-items: center; gap: var(--admin-space-3); margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section); }
.section-heading h2 svg { color: var(--workbench-primary); }
.section-heading p { overflow-wrap: anywhere; margin: 5px 0 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 1.55; }
.is-danger { color: var(--el-color-danger) !important; }
.mapping-section, .detail-section { display: grid; min-width: 0; gap: var(--admin-space-5); }
.mapping-section .section-heading, .detail-section .section-heading { margin-bottom: 0; }
.mapping-groups { display: grid; gap: var(--admin-space-5); }
.mapping-group { display: grid; gap: var(--admin-space-3); }
.mapping-group h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-body); }
.mapping-row { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(320px, 1fr); align-items: center; gap: var(--admin-space-6); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); background: var(--cc-surface-soft, #f8fafc); }
.mapping-row { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(320px, 1fr); align-items: center; gap: var(--admin-space-6); padding: var(--admin-space-5); border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); background: var(--cc-surface-soft, #f8fafc); }
.mapping-row > div { display: grid; min-width: 0; gap: var(--admin-space-1); }
.mapping-row strong { overflow-wrap: anywhere; color: var(--workbench-text); font-size: var(--admin-font-body); }
.mapping-row span { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
@@ -580,8 +579,8 @@ onMounted(() => {
.mapping-load-error strong { color: var(--cc-danger); font-size: var(--admin-font-body); }
.mapping-load-error p { margin: var(--admin-space-1) 0; color: var(--workbench-text); font-size: var(--admin-font-caption); }
.channel-tabs { width: 100%; min-width: 0; margin-bottom: 0; overflow: hidden; }
.channel-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
.channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; }
.channel-meta { display: flex; flex-wrap: wrap; gap: var(--admin-space-2) var(--admin-space-5); margin: calc(-1 * var(--admin-space-1)) 0 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); }
@media (max-width: 720px) {
.detail-heading, .mapping-load-error { align-items: stretch; flex-direction: column; }
.detail-toolbar { width: 100%; }
@@ -56,8 +56,8 @@ describe('CarrierRatesView price-book source contract', () => {
})
it('shows all source-backed prototype price metadata without inventing cost breakdowns', () => {
for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注', '生效状态']) {
expect(detailSource).toContain(`label="${label}"`)
for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注']) {
expect(detailSource).toContain(`<dt>${label}</dt>`)
}
for (const field of ['referenceTransit', 'latestWarehouseEntry', 'estimatedDepartureText', 'surchargeDescription', 'remark']) {
expect(detailSource).toContain(`selectedChannel?.${field}`)
@@ -82,15 +82,15 @@ const queryFields = computed<QueryField[]>(() => [
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'scope', label: '报价范围', minWidth: 190, slotName: 'scope' },
{ key: 'version', label: '价格表版本', minWidth: 180, slotName: 'version' },
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'scope', label: '报价范围', minWidth: 160, slotName: 'scope' },
{ key: 'version', label: '价格表版本', width: 110, slotName: 'version' },
{ key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'contents', label: '解析结果', minWidth: 155, slotName: 'contents' },
{ key: 'issues', label: '校验结果', minWidth: 150, slotName: 'issues' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
{ key: 'contents', label: '解析结果', minWidth: 170, slotName: 'contents' },
{ key: 'issues', label: '校验结果', width: 140, slotName: 'issues' },
{ key: 'createdAt', label: '上传时间', minWidth: 165, formatter: (row: PlatformPriceBookVersion) => formatDateTime(row.createdAt) },
{ key: 'effectivePeriod', label: '实际生效区间', minWidth: 290, formatter: (row: PlatformPriceBookVersion) => formatEffectivePeriod(row) },
{ key: 'effectivePeriod', label: '实际生效区间', minWidth: 260, formatter: (row: PlatformPriceBookVersion) => formatEffectivePeriod(row) },
]
const canEdit = computed(() => access.can('firstleg:fba-pricing:rate:edit'))
@@ -382,7 +382,7 @@ onMounted(() => {
<template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template>
<template #scope="{ row }"><el-tag :type="(row as PlatformPriceBookVersion).customerId === '0' ? 'info' : 'warning'" effect="light">{{ priceBookScopeLabel((row as PlatformPriceBookVersion).customerId) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="priceBookStatusMeta((row as PlatformPriceBookVersion).status).type" effect="light">{{ priceBookStatusMeta((row as PlatformPriceBookVersion).status).label }}</el-tag></template>
<template #contents="{ row }"><div class="metric-cell"><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong><span>渠道</span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong><span>价格行</span></div></template>
<template #contents="{ row }"><div class="metric-cell"><span><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong> 渠道</span><span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong> 价格行</span></div></template>
<template #issues="{ row }"><div class="issue-counts"><span :class="{ 'is-blocking': (row as PlatformPriceBookVersion).blockingErrorCount }">阻断 {{ (row as PlatformPriceBookVersion).blockingErrorCount }}</span><span>警告 {{ (row as PlatformPriceBookVersion).warningCount }}</span></div></template>
<template #empty><el-empty description="暂无船期价格表版本" /></template>
<template #pagination-left>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</template>
@@ -423,6 +423,7 @@ onMounted(() => {
.version-cell strong { color: var(--workbench-text); }
.version-cell span, .metric-cell span { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.metric-cell, .issue-counts { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--admin-space-1) var(--admin-space-3); }
.metric-cell > span, .issue-counts > span { white-space: nowrap; }
.metric-cell strong { color: var(--workbench-text); font-size: var(--admin-font-body); }
.issue-counts { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; }
+1 -1
View File
@@ -5,7 +5,7 @@
| 页面 | 操作 | 原型承载/布局 | 当前实现及本轮结论 |
|---|---|---|---|
| 费用项目 | 列表 | 页面内表格,原型无价格列 | 沿用列表,增加默认价格列,空值显示“—” |
| 费用项目 | 新增、编辑 | 原型未定义表单尺寸与承载方式 | 沿用现有 620px 抽屉与表单,增加可空默认价格及说明 |
| 费用项目 | 新增、编辑 | 原型未定义表单尺寸与承载方式 | 2026-10-27 用户确认改为居中弹框(760px,受视口宽度限制),三个分区使用白底和中性边框;保留可空默认价格、字段分组及保存行为 |
| 入库费用 | 新增、编辑、调整时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入单价和金额,支持人工覆盖,回显保留原值 |
| 柜次费用 | 新增时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入金额,支持人工覆盖,调整回显保留原值 |
@@ -5,13 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import FeeItemsView from './FeeItemsView.vue'
import * as api from '../api/platformFeeItems'
// Preserve the validator constructor interop through Element Plus's supported Node export.
const { default: ElementPlus, ElCheckbox, ElDrawer, ElInputNumber, ElSelect } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
const { default: ElementPlus, ElCheckbox, ElDialog, ElInputNumber, ElSelect } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
vi.mock('../api/platformFeeItems', () => ({ listPlatformFeeItems: vi.fn(), getPlatformFeeItem: vi.fn(), createPlatformFeeItem: vi.fn(), updatePlatformFeeItem: vi.fn(), changePlatformFeeItemStatus: vi.fn(), deletePlatformFeeItem: vi.fn() }))
let record: api.PlatformFeeItemResource
@@ -28,7 +27,7 @@ async function openEditor(create = false) {
const wrapper = await openView(); button(create ? '新增费用项目' : '编辑').click(); await flushPromises(); return wrapper
}
async function setText(placeholder: string, value: string) {
const input = document.querySelector<HTMLInputElement>(`.el-drawer [placeholder="${placeholder}"]`)!
const input = document.querySelector<HTMLInputElement>(`.el-dialog [placeholder="${placeholder}"]`)!
input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
}
async function check(wrapper: Awaited<ReturnType<typeof openView>>, label: string, value: boolean) {
@@ -51,12 +50,14 @@ beforeEach(() => {
})
describe('fee item editing and confirmations', () => {
it('creates from the original compact drawer with a zero default and all direction flags', async () => {
it('creates from a centered white dialog with a zero default and all direction flags', async () => {
const wrapper = await openEditor(true)
expect(wrapper.findComponent(AppDrawer).props()).toMatchObject({ title: '新增费用项目', size: 'min(620px, 94vw)' })
expect(document.querySelector('.el-drawer')?.classList.contains('admin-density-drawer--editor')).toBe(true)
expect(document.querySelectorAll('.el-drawer .density-dialog-section')).toHaveLength(3)
expect(document.querySelector('.el-dialog')).toBeNull()
expect(wrapper.findComponent(AppDialog).props()).toMatchObject({ title: '新增费用项目', width: 'min(760px, 94vw)' })
expect(document.querySelector('.el-dialog')?.classList.contains('admin-density-dialog')).toBe(true)
expect(document.querySelectorAll('.el-dialog .density-dialog-section')).toHaveLength(3)
expect(document.querySelector('.fee-item-editor .density-dialog-section[data-tone]')).toBeNull()
expect(wrapper.findComponent(ElDialog).props('alignCenter')).toBe(true)
expect(document.querySelector('.el-drawer')).toBeNull()
await setText('请输入费用名称', ' 新增操作费 '); await setText('例如 仓储成本', ' 操作费 ')
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 0)
await check(wrapper, '提货环节应付', true); await check(wrapper, '提货环节应收', true)
@@ -64,33 +65,33 @@ describe('fee item editing and confirmations', () => {
wrapper.findComponent(ElSelect).vm.$emit('update:modelValue', 'TRUCKING')
await wrapper.findAllComponents(ElCheckbox).find(node => node.text() === '作为分摊费用')!.get('input').setValue(true)
await setText('请输入适用场景或核算说明', ' 新说明 ')
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.createPlatformFeeItem).toHaveBeenCalledWith({ category: '操作费', tradeScope: 'DOMESTIC', feeTypeName: '新增操作费', defaultPrice: 0, supplierCategory: 'TRUCKING', pickupPayable: true, pickupReceivable: true, warehousePayable: true, warehouseReceivable: true, allocationFee: true, description: '新说明' })
expect(useUnsavedWorkStore().entries).toHaveLength(0)
})
it('validates required fields and payable supplier category without losing entered values', async () => {
const wrapper = await openEditor(true)
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.createPlatformFeeItem).not.toHaveBeenCalled()
await vi.waitFor(() => expect(document.querySelector('.el-drawer')?.textContent).toContain('请输入费用名称'))
await vi.waitFor(() => expect(document.querySelector('.el-dialog')?.textContent).toContain('请输入费用名称'))
await setText('请输入费用名称', '新费用'); await setText('例如 仓储成本', '操作费')
await check(wrapper, '提货环节应付', true)
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.createPlatformFeeItem).not.toHaveBeenCalled()
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true)
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('新费用')
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
expect(document.querySelector<HTMLInputElement>('.el-dialog [placeholder="请输入费用名称"]')!.value).toBe('新费用')
})
it('edits the same long ID and clears the supplier payload when payable directions are removed', async () => {
record = { ...record, pickupPayable: true, supplierCategory: 'TRUCKING', defaultPrice: 25 }
const wrapper = await openEditor()
expect(wrapper.findComponent(AppDrawer).props('title')).toBe('编辑费用项目 · 测试操作费')
expect(document.querySelector('.el-drawer')?.textContent).not.toContain(privateId)
expect(wrapper.findComponent(AppDialog).props('title')).toBe('编辑费用项目 · 测试操作费')
expect(document.querySelector('.el-dialog')?.textContent).not.toContain(privateId)
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', null)
await check(wrapper, '提货环节应付', false)
expect(document.querySelector('.el-drawer [placeholder="请选择供应商类别"]')).toBeNull()
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
expect(document.querySelector('.el-dialog [placeholder="请选择供应商类别"]')).toBeNull()
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.updatePlatformFeeItem).toHaveBeenCalledWith(privateId, expect.objectContaining({ defaultPrice: null, supplierCategory: null, pickupPayable: false }))
})
@@ -99,17 +100,17 @@ describe('fee item editing and confirmations', () => {
vi.mocked(api.updatePlatformFeeItem).mockImplementation(() => new Promise((_resolve,reject) => { rejectSave = reject }))
const wrapper = await openEditor(); await setText('请输入费用名称', '修改后费用')
expect(useUnsavedWorkStore().entries[0]).toMatchObject({ key: 'fee-item-editor', dirty: true, requiredPermission: 'firstleg:fee-item:edit' })
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
expect(wrapper.findComponent(ElDrawer).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(button('取消', '.el-drawer__footer button').disabled).toBe(true)
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(wrapper.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(button('取消', '.el-dialog__footer button').disabled).toBe(true)
rejectSave(new Error('保存失败')); await flushPromises()
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true)
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('修改后费用')
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
expect(document.querySelector<HTMLInputElement>('.el-dialog [placeholder="请输入费用名称"]')!.value).toBe('修改后费用')
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
vi.mocked(api.updatePlatformFeeItem).mockResolvedValue(record)
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.updatePlatformFeeItem).toHaveBeenCalledTimes(2)
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
})
it('keeps unsaved registration and the existing permission-change closure', async () => {
@@ -118,7 +119,7 @@ describe('fee item editing and confirmations', () => {
await setText('请输入费用名称', '权限变化前的修改')
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
useAuthStore().permissions = ['firstleg:fee-item:view']; useAccessPreviewStore().initializeAfterLogin(); await flushPromises()
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
expect(useUnsavedWorkStore().entries).toHaveLength(0)
expect(api.updatePlatformFeeItem).not.toHaveBeenCalled()
})
+8 -9
View File
@@ -23,7 +23,6 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -430,22 +429,22 @@ onMounted(() => {
</DataTable></section>
</ListPageLayout>
<AppDrawer class="admin-density admin-density-drawer admin-density-drawer--editor admin-workbench is-drawer"
<AppDialog class="admin-density-dialog admin-workbench is-dialog fee-item-editor"
v-model="editorOpen"
:title="editorTitle"
size="min(620px, 94vw)"
width="min(760px, 94vw)" align-center
:loading="editorLoading"
:confirm-loading="saving"
>
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="rules" label-position="left" label-width="100px" :disabled="saving || editorLoading">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用基本信息">
<el-form class="density-form density-form--outlined density-dialog-sections" ref="formRef" :model="form" :rules="rules" label-position="left" label-width="100px" :disabled="saving || editorLoading">
<section class="density-dialog-section workbench-evidence" aria-label="费用基本信息">
<h3 class="density-dialog-section__title">基本信息 <InlineHelp title="默认价格说明">选择费用项目时自动带入,使用时仍可修改;清空表示不设置默认价格。</InlineHelp></h3>
<table class="density-table density-table--editable fee-item-editor-table"><tbody>
<tr><td><el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item></td><td><el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item></td></tr>
<tr><td><el-form-item label="贸易范围" prop="tradeScope" required><el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" /></el-form-item></td><td><el-form-item label="默认价格(CNY)" prop="defaultPrice"><el-input-number v-model="form.defaultPrice" :min="0" :max="9999999999.99" :precision="2" controls-position="right" placeholder="不填则由使用时手工录入" /></el-form-item></td></tr>
<tr><td><el-form-item label="贸易范围" prop="tradeScope" required><el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" /></el-form-item></td><td><el-form-item label="默认价格(CNY)" label-width="120px" prop="defaultPrice"><el-input-number v-model="form.defaultPrice" :min="0" :max="9999999999.99" :precision="2" controls-position="right" placeholder="选填" /></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用方向与供应商">
<section class="density-dialog-section workbench-evidence" aria-label="费用方向与供应商">
<h3 class="density-dialog-section__title">费用方向 <InlineHelp title="费用方向说明">选择费用在提货和仓库环节的应收、应付属性。</InlineHelp></h3>
<table class="density-table density-table--editable fee-direction-table"><thead><tr><th scope="col">环节</th><th scope="col">应付</th><th scope="col">应收</th></tr></thead><tbody>
<tr><th scope="row">提货环节</th><td><el-checkbox v-model="form.pickupPayable" aria-label="提货环节应付" /></td><td><el-checkbox v-model="form.pickupReceivable" aria-label="提货环节应收" /></td></tr>
@@ -454,7 +453,7 @@ onMounted(() => {
<tr v-if="hasPayableDirection"><td colspan="3"><el-form-item label="供应商类别" required><el-select v-model="form.supplierCategory" placeholder="请选择供应商类别"><el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用适用说明">
<section class="density-dialog-section workbench-evidence" 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.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></el-form-item></td></tr></tbody></table>
</section>
@@ -465,7 +464,7 @@ onMounted(() => {
<el-button type="primary" :loading="saving" :disabled="editorLoading" @click="saveFeeItem"><Save :size="16" />保存费用项目</el-button>
</div>
</template>
</AppDrawer>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog"
v-model="statusOpen"
+24 -1
View File
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { refundFinanceConsumption } from './finance'
import { actionReconciliationStatement, adjustFinanceAccount, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, downloadFinanceRechargeVoucher, editFinanceFeeSource, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReceivableStatistics, getReconciliationStatement, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceRechargeRequests, listFinanceStatements, reviewFinanceFeeSource, reviewFinanceRechargeRequest, updateFinanceBill, updateFinanceCreditLimit, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance'
describe('finance api', () => {
@@ -88,7 +89,7 @@ describe('finance api', () => {
})
it('uses the formal G1 account ledger path and fixed page DTO', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款', businessNo: 'AC202608110001', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceType: 'MANUAL_ADJUSTMENT', sourceNo: 'RC-91', createdAt: '2026-08-11T12:00:00' }], page: { page: 1, pageSize: 50, total: 1 } })
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款', walletBusinessNo: 'AC202608110001', changeType: 'RECHARGE', walletAmount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceType: 'MANUAL_ADJUSTMENT', sourceNo: 'RC-91', createdAt: '2026-08-11T12:00:00' }], page: { page: 1, pageSize: 50, total: 1 } })
const result = await listFinanceAccountLedger('10001', { page: 1, size: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' })
@@ -96,6 +97,22 @@ describe('finance api', () => {
expect(result.records[0]).toMatchObject({ ledgerId: '81', businessNo: 'AC202608110001', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceNo: 'RC-91' })
})
it('preserves storage rent freeze and settlement amounts from the account ledger DTO', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [
{ ledgerId: '61', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230003', changeType: 'CONSUMPTION', walletAmount: -10.01, actualBalanceAfter: 9905.22, frozenBalanceAfter: 35.47, availableBalanceAfter: 9969.73 },
{ ledgerId: '60', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230002', changeType: 'FREEZE', walletAmount: -10.01, actualBalanceAfter: 9915.23, frozenBalanceAfter: 45.48, availableBalanceAfter: 9969.73 },
{ ledgerId: '59', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230001', changeType: 'FREEZE', walletAmount: 0 },
{ ledgerId: '58', documentType: 'SETTLEMENT' },
], page: { total: 4 } })
const { records } = await listFinanceAccountLedger('10001')
expect(records[0]).toMatchObject({ businessNo: 'AC202610230003', amount: '-10.01', actualBalanceAfter: '9905.22', frozenBalanceAfter: '35.47', availableBalanceAfter: '9969.73' })
expect(records[1]).toMatchObject({ businessNo: 'AC202610230002', amount: '-10.01', actualBalanceAfter: '9915.23', frozenBalanceAfter: '45.48', availableBalanceAfter: '9969.73' })
expect(records[2].amount).toBe('0')
expect(records[3].amount).toBeNull()
})
it('uses frozen recharge review and account adjustment contracts', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ requestId: 91, requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: 81, voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', createdAt: '2026-09-04T10:00:00' }], page: { page: 1, pageSize: 200, total: 1 } })
const post = vi.spyOn(http, 'post')
@@ -311,3 +328,9 @@ describe('finance api', () => {
})
})
it('submits linked consumption refund with the caller retry key', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { changeType: 'REFUND', referenceNo: 'AC-61' } })
await expect(refundFinanceConsumption('9007199254740993', '61', { amount: '12.30', reason: '多扣' }, 'stable-key')).resolves.toMatchObject({ referenceNo: 'AC-61' })
expect(post).toHaveBeenCalledWith('/platform/finance/v1/accounts/9007199254740993/ledger/61/refunds', { amount: '12.30', reason: '多扣' }, { headers: { 'Idempotency-Key': 'stable-key' } })
})
+6 -1
View File
@@ -155,7 +155,7 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag
documentType: stringValue(item.documentType), documentId: stringValue(item.documentId), documentNo: stringValue(item.documentNo),
receivableChange: numberValue(item.receivableChange), payableChange: numberValue(item.payableChange),
outstandingReceivableAfter: numberValue(item.outstandingReceivableAfter), operatorName: stringValue(item.operatorName), remark: stringValue(item.remark),
businessNo: nullableStringValue(item.businessNo), changeType: nullableStringValue(item.changeType), amount: nullableStringValue(item.amount),
businessNo: nullableStringValue(item.walletBusinessNo), changeType: nullableStringValue(item.changeType), amount: nullableStringValue(item.walletAmount),
actualBalanceAfter: nullableStringValue(item.actualBalanceAfter), frozenBalanceAfter: nullableStringValue(item.frozenBalanceAfter),
availableBalanceAfter: nullableStringValue(item.availableBalanceAfter), sourceType: nullableStringValue(item.sourceType),
creditLimitAfter: nullableStringValue(item.creditLimitAfter), creditUsedAfter: nullableStringValue(item.creditUsedAfter),
@@ -208,6 +208,11 @@ export async function updateFinanceCreditLimit(customerId: string, payload: { cr
return unwrapApiResponse(response)
}
export async function refundFinanceConsumption(customerId: string, ledgerId: string, payload: { amount: string; reason: string }, key: string) {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/accounts/${encodeURIComponent(customerId)}/ledger/${encodeURIComponent(ledgerId)}/refunds`, payload, { headers: { 'Idempotency-Key': key } })
return unwrapApiResponse(response)
}
export async function listFinanceFeeSources(params: {
page?: number
size?: number
+29 -1
View File
@@ -7,7 +7,6 @@
.admin-density .finance-detail-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--admin-space-3); }
.admin-density .finance-detail-context { min-width: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.admin-density .statement-summary-grid .statement-summary-item--remark { grid-column: span 2; }
.admin-density .settlement-summary.density-detail-overview { padding: 0; border: 0; }
.admin-density .statement-summary-section { padding: var(--admin-space-5); }
.admin-density .account-metrics .is-negative,
.admin-density .density-detail-overview .is-danger,
@@ -15,3 +14,32 @@
.admin-density .density-detail-overview .is-success,
.admin-density-dialog .partial-summary .is-success { color: var(--el-color-success); }
.admin-density-dialog .partial-summary { margin-bottom: var(--admin-space-5); }
/* Outrank AdminPage scoped spacing regardless of stylesheet load order; the toolbar supplies the gap. */
.admin-workbench.receivable-statistics-page.admin-page.is-spacing-compact,
.admin-workbench.finance-settlement-page.admin-page.is-spacing-compact { gap: 0; }
.admin-workbench .finance-metric-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
@media (max-width: 720px) {
.admin-workbench .finance-metric-grid { grid-template-columns: 1fr; }
}
/* The outer AdminPage owns the screen inset for every finance detail section. */
@media screen {
.admin-density .detail-page-layout.finance-density-detail > :is(.detail-page-layout__header, .detail-page-layout__summary, .detail-page-layout__content, .detail-page-layout__footer),
.admin-density .finance-density-detail > .detail-page-layout__header > .density-detail-header,
.admin-density .finance-density-detail > .detail-page-layout__summary > :is(.density-detail-overview, .statement-summary-section),
.admin-density .finance-density-detail > .detail-page-layout__content > .settlement-detail-content {
padding-inline: 0;
}
}
/* Detail sections share one vertical rhythm instead of stacking section padding. */
.admin-density .finance-density-detail > .detail-page-layout__content,
.admin-density .finance-density-detail .settlement-detail-content {
display: flex;
flex-direction: column;
gap: var(--admin-space-5);
}
.admin-density .finance-density-detail > .detail-page-layout__content .density-form-section {
padding-block: 0;
}
@@ -6,7 +6,7 @@ import { ElMessage } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import { listPlatformTenants } from '@/modules/iam'
import * as api from '../api/finance'
import type { FinanceAccountSummary, FinanceRechargeRequest } from '../types'
import type { FinanceAccountSummary, FinanceLedgerEntry, FinanceRechargeRequest } from '../types'
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
@@ -17,7 +17,7 @@ vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('../api/finance', () => ({
listFinanceAccounts: vi.fn(), listFinanceAccountLedger: vi.fn(), listFinanceRechargeRequests: vi.fn(),
reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), updateFinanceCreditLimit: vi.fn(),
reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), refundFinanceConsumption: vi.fn(), updateFinanceCreditLimit: vi.fn(),
downloadFinanceRechargeVoucher: vi.fn(), getFinanceCustomerTerms: vi.fn(), updateFinanceCustomerTerms: vi.fn(),
}))
const account: FinanceAccountSummary = {
@@ -287,3 +287,105 @@ describe('customer wallet native dialogs', () => {
expect(activeDialog(wrapper).find('.voucher-preview__image').exists()).toBe(true)
})
})
const consumption: FinanceLedgerEntry = {
ledgerId: '61', businessNo: 'AC-CONSUME-61', changeType: 'CONSUMPTION', amount: '-80.00',
sourceNo: 'RELABEL-1', occurredAt: '', businessType: 'ADJUSTMENT', documentType: '', documentId: '', documentNo: '',
receivableChange: 0, payableChange: 0, outstandingReceivableAfter: 0, operatorName: '', remark: '',
}
async function openRefundDialog() {
vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [consumption], total: 1, pages: 1 })
const wrapper = await mountPage()
await click(wrapper, '退款', '.ledger-table button')
return { wrapper, dialog: activeDialog(wrapper) }
}
describe('linked original consumption refunds', () => {
it.each(['', '0', '-1', '80.01', '1.001', 'NaN'])('rejects invalid refund %s', async amount => {
const { dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue(amount)
await dialog.get('textarea').setValue('测试退款')
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
})
it('requires a reason and keeps the original consumption visible', async () => {
const { dialog } = await openRefundDialog()
expect(dialog.text()).toContain('AC-CONSUME-61')
expect(dialog.text()).toContain('RELABEL-1')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
})
it('locks a pending refund, preserves failures, and retries the identical payload with the same key', async () => {
const pending = deferred<Record<string, unknown>>()
vi.mocked(api.refundFinanceConsumption).mockReturnValueOnce(pending.promise).mockResolvedValueOnce({})
const { wrapper, dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue('12.3')
await dialog.get('textarea').setValue(' 原单多扣 ')
await click(dialog, '确认退款')
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
await click(dialog, '取消')
expect(dialog.props('modelValue')).toBe(true)
pending.reject(failure())
await flushPromises()
expect(dialog.text()).toContain('测试操作未成功')
const first = vi.mocked(api.refundFinanceConsumption).mock.calls[0]!
expect(first).toEqual([account.customerId, '61', { amount: '12.30', reason: '原单多扣' }, expect.stringMatching(/^finance-refund-/)])
await click(dialog, '确认退款')
expect(vi.mocked(api.refundFinanceConsumption).mock.calls[1]).toEqual(first)
expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('账户流水')
})
it('creates a new key for changed amount after a rejected request', async () => {
vi.mocked(api.refundFinanceConsumption).mockRejectedValue(failure())
const { dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
await dialog.get('textarea').setValue('退款')
await click(dialog, '确认退款')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('11')
await click(dialog, '确认退款')
const calls = vi.mocked(api.refundFinanceConsumption).mock.calls
expect(calls[0]![3]).not.toBe(calls[1]![3])
})
it('hides refunds without adjustment permission and labels refund original references', async () => {
can.mockImplementation(permission => permission !== 'platform:finance:customer-account:adjust')
vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [consumption, { ...consumption, ledgerId: '62', changeType: 'REFUND', amount: '10.00', referenceNo: 'AC-CONSUME-61' }], total: 2, pages: 1 })
const wrapper = await mountPage()
expect(wrapper.findAll('.ledger-table button').some(button => button.text() === '退款')).toBe(false)
expect(wrapper.text()).toContain('消费退款')
expect(wrapper.text()).toContain('原消费:')
})
})
describe('customer wallet detail error recovery', () => {
it.each(['ACCOUNT', 'LEDGER'] as const)('reloads the current detail after %s fails without same-route navigation', async failurePoint => {
if (failurePoint === 'ACCOUNT') vi.mocked(api.listFinanceAccounts).mockRejectedValueOnce(failure())
else vi.mocked(api.listFinanceAccountLedger).mockRejectedValueOnce(failure())
const wrapper = await mountPage()
expect(wrapper.get('.load-error-state').text()).toContain('测试操作未成功')
const pending = deferred<Awaited<ReturnType<typeof api.listFinanceAccounts>>>()
vi.mocked(api.listFinanceAccounts).mockReturnValueOnce(pending.promise)
await click(wrapper, '重新加载', '.load-error-state button')
expect(api.listFinanceAccounts).toHaveBeenCalledTimes(2)
pending.resolve({ records: [{ ...account, actualBalance: '90.00' }], total: 1, pages: 1 })
await flushPromises()
expect(wrapper.find('.load-error-state').exists()).toBe(false)
expect(wrapper.get('[aria-label="客户资金账户余额"]').text()).toContain('¥90.00')
expect(wrapper.find('.ledger-table').exists()).toBe(true)
expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(failurePoint === 'ACCOUNT' ? 1 : 2)
expect(api.listFinanceRechargeRequests).toHaveBeenCalledTimes(failurePoint === 'ACCOUNT' ? 1 : 2)
})
})
it('enforces the refund contract reason limit before submitting', async () => {
const { wrapper, dialog } = await openRefundDialog()
expect(dialog.get('textarea').attributes('maxlength')).toBe('500')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
const vm = wrapper.vm as unknown as { refundForm: { reason: string } }
vm.refundForm.reason = '退'.repeat(501)
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
vm.refundForm.reason = '退'.repeat(500)
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).toHaveBeenCalledWith(account.customerId, consumption.ledgerId, { amount: '10.00', reason: '退'.repeat(500) }, expect.any(String))
})
@@ -18,7 +18,7 @@ vi.mock('../api/finance', () => ({
listFinanceAccountLedger: vi.fn(),
listFinanceRechargeRequests: vi.fn(),
reviewFinanceRechargeRequest: vi.fn(),
adjustFinanceAccount: vi.fn(),
adjustFinanceAccount: vi.fn(), refundFinanceConsumption: vi.fn(),
updateFinanceCreditLimit: vi.fn(),
downloadFinanceRechargeVoucher: vi.fn(),
getFinanceCustomerTerms: vi.fn(),
@@ -65,7 +65,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).toContain('v-else-if="!detailOpen"')
expect(page).toContain('返回客户账户')
expect(page).toContain('<DetailPageLayout')
expect(page).toContain('class="density-detail-overview account-metrics"')
expect(page).toContain('class="account-metrics"')
for (const metric of ['实际余额', '冻结金额', '总可用余额', '授信额度', '已用授信', '可用授信']) expect(page).toContain(metric)
expect(page).toContain('class="ledger-query workbench-query"')
expect(page).toContain('@search="ledgerPage = 1; void loadLedger($event)"')
@@ -83,18 +83,18 @@ describe('FinanceCustomerAccountsView delivery', () => {
it('keeps term editing aligned with the account summary and nearby actions', () => {
for (const marker of ['class="term-section density-form-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
expect(page).toContain('density-detail-overview account-metrics')
expect(page).toContain('account-metrics')
for (const column of ['实际余额(CNY)', '冻结金额(CNY)', '总可用余额(CNY)', '授信额度(CNY)', '已用授信(CNY)', '待审核充值']) expect(page).toContain(column)
expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)')
expect(page).toContain('justify-content: start')
expect(page).toContain('class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface"')
expect(page).toContain('class="density-detail-header"')
expect(page).toContain('padding: var(--admin-space-4) 0')
expect(page).toContain('class="detail-table-scroll workbench-table"')
expect(page).toContain('class="detail-table-scroll workbench-table is-page-surface"')
expect(page).toContain('class="ledger-identifier"')
expect(page).toContain('class="ledger-document"')
expect(page).toContain('text-overflow: ellipsis')
expect(page).toContain('white-space: nowrap')
expect(page).toContain('overflow-wrap: anywhere')
expect(page).not.toContain('text-overflow: ellipsis')
})
it('keeps account detail and term editing readable across the 1920 workspace', () => {
@@ -186,7 +186,10 @@ describe('FinanceCustomerAccountsView delivery', () => {
await flushPromises()
expect(wrapper.text()).toContain('客户A')
expect(wrapper.text()).toContain('C-001')
const profile = wrapper.find('.customer-profile-section')
expect(profile.text()).toContain('客户名称客户A')
expect(profile.text()).not.toContain('客户编号')
expect(profile.text()).not.toContain('C-001')
expect(wrapper.text()).toContain('张三')
expect(wrapper.text()).toContain('13800000000')
const metrics = wrapper.find('.account-metrics')
@@ -194,7 +197,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(metrics.text()).toContain('实际余额¥126580.50 CNY')
expect(metrics.text()).toContain('冻结金额¥8500.00 CNY')
expect(metrics.text()).toContain('总可用余额¥138080.50 CNY')
expect(metrics.text()).toContain('授信额度¥20000.00 2028-09-10 到期')
expect(metrics.text()).toContain('授信额度¥20000.00 CNY2028-09-10 到期')
expect(metrics.text()).toContain('已用授信¥0.00 CNY')
expect(metrics.text()).toContain('可用授信¥20000.00 CNY')
for (const auxiliary of ['待开账金额', '本期已收', '总成本', '已付成本', '已收客户款', '客户线下支付']) expect(metrics.text()).not.toContain(auxiliary)
@@ -4,7 +4,7 @@ import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, CreditCard, Download, MinusCircle, PlusCircle, Save } from '@lucide/vue'
import { ArrowLeft, CircleCheck, CreditCard, Download, LockKeyhole, MinusCircle, PlusCircle, Save, Wallet } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
@@ -22,7 +22,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance'
import { refundFinanceConsumption, adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance'
import { financeBusinessTypeLabel } from '../status'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry, FinanceRechargeRequest } from '../types'
@@ -45,6 +45,7 @@ const saving = ref(false)
const selected = ref<FinanceAccountSummary | null>(null)
const ledger = ref<FinanceLedgerEntry[]>([])
const ledgerLoading = ref(false)
const detailLoading = ref(false)
const ledgerError = ref<ApiErrorDetails | null>(null)
const selectedTerms = ref<FinanceCustomerTerms | null>(null)
const creditDays = ref(30)
@@ -57,6 +58,12 @@ const rechargeLoading = ref(false)
const rechargeError = ref<ApiErrorDetails | null>(null)
const accountExportBusy = ref(false)
const ledgerExportBusy = ref(false)
const refundOpen = ref(false)
const refundSaving = ref(false)
const refundSource = ref<{ customerId: string; entry: FinanceLedgerEntry } | null>(null)
const refundForm = reactive({ amount: '', reason: '' })
const refundError = ref<ApiErrorDetails | null>(null)
let refundAttempt = { payload: '', key: '' }
const adjustmentOpen = ref(false)
const adjustmentSaving = ref(false)
const adjustmentAccount = ref<FinanceAccountSummary | null>(null)
@@ -93,13 +100,13 @@ const columns: DataTableColumn[] = [
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
{ key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' },
{ key: 'actualBalance', label: '实际余额(CNY)', width: 155, slotName: 'actualBalance' },
{ key: 'frozenBalance', label: '冻结金额(CNY)', width: 150, slotName: 'frozenBalance' },
{ key: 'availableBalance', label: '总可用余额(CNY)', width: 155, slotName: 'availableBalance' },
{ key: 'creditLimit', label: '授信额度(CNY)', width: 155, slotName: 'creditLimit' },
{ key: 'actualBalance', label: '实际余额(CNY)', align: 'right', width: 155, slotName: 'actualBalance' },
{ key: 'frozenBalance', label: '冻结金额(CNY)', align: 'right', width: 150, slotName: 'frozenBalance' },
{ key: 'availableBalance', label: '总可用余额(CNY)', align: 'right', width: 155, slotName: 'availableBalance' },
{ key: 'creditLimit', label: '授信额度(CNY)', align: 'right', width: 155, slotName: 'creditLimit' },
{ key: 'creditExpiresOn', label: '授信有效期', width: 140, slotName: 'creditExpiresOn' },
{ key: 'creditUsed', label: '已用授信(CNY)', width: 150, slotName: 'creditUsed' },
{ key: 'pendingRechargeCount', label: '待审核充值', width: 120, slotName: 'pendingRechargeCount' },
{ key: 'creditUsed', label: '已用授信(CNY)', align: 'right', width: 150, slotName: 'creditUsed' },
{ key: 'pendingRechargeCount', label: '待审核充值', align: 'right', width: 120, slotName: 'pendingRechargeCount' },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
]
@@ -114,19 +121,20 @@ const ledgerQueryFields: QueryField[] = [
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
]
const ledgerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' },
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, slotName: 'amount' },
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, align: 'right', slotName: 'amount' },
{ key: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' },
{ key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, slotName: 'actualBalanceAfter' },
{ key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, slotName: 'frozenBalanceAfter' },
{ key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, slotName: 'availableBalanceAfter' },
{ key: 'creditUsedAfter', label: '变动后已用授信', width: 160, slotName: 'creditUsedAfter' },
{ key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, align: 'right', slotName: 'actualBalanceAfter' },
{ key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, align: 'right', slotName: 'frozenBalanceAfter' },
{ key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, align: 'right', slotName: 'availableBalanceAfter' },
{ key: 'creditUsedAfter', label: '变动后已用授信', width: 160, align: 'right', slotName: 'creditUsedAfter' },
{ key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
]
const rechargeColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'requestNo', label: '申请单号', minWidth: 190 }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' },
{ key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' },
{ key: 'amount', label: '充值金额(CNY)', width: 150, align: 'right', slotName: 'amount' },
{ key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'reviewedByName', label: '审核人', width: 130 },
{ key: 'reviewRemark', label: '审核备注', minWidth: 180 }, { key: 'reviewedAt', label: '审核时间', width: 170, slotName: 'reviewedAt' },
@@ -204,7 +212,15 @@ async function refreshWalletDetail() {
if (!selected.value) return
await Promise.all([loadRechargeRequests(), loadLedger()])
}
async function retryDrawer() { if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, editing.value) }
async function retryDrawer() {
if (!canView.value || detailLoading.value) return
if (editing.value) {
if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, true)
return
}
const customerId = String(route.params.customerId || selected.value?.customerId || '')
if (customerId) await restoreAccountDetail(customerId)
}
async function saveTerms() {
if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return }
saving.value = true
@@ -237,7 +253,41 @@ function paymentMethodLabel(value: unknown) {
return ({ BANK_TRANSFER: '银行转账', WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', OTHER: '其他' } as Record<string, string>)[String(value)] || '其他'
}
function walletBusinessType(value: unknown) {
return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', CREDIT_LIMIT: '授信调整' } as Record<string, string>)[String(value)] || ledgerBusinessType(value)
return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', REFUND: '消费退款', CREDIT_LIMIT: '授信调整' } as Record<string, string>)[String(value)] || ledgerBusinessType(value)
}
function canRefund(entry: FinanceLedgerEntry) {
return canAdjust.value && entry.changeType === 'CONSUMPTION' && !!entry.ledgerId && Number(entry.amount) < 0
}
function openRefund(entry: FinanceLedgerEntry) {
if (!selected.value || !canRefund(entry) || refundSaving.value) return
refundSource.value = { customerId: selected.value.customerId, entry: { ...entry } }
Object.assign(refundForm, { amount: '', reason: '' })
refundError.value = null
refundAttempt = { payload: '', key: '' }
refundOpen.value = true
}
async function submitRefund() {
const source = refundSource.value
if (refundSaving.value || !source || !canRefund(source.entry) || selected.value?.customerId !== source.customerId) return
const raw = refundForm.amount.trim()
const amount = Number(raw)
if (!/^\d+(?:\.\d{1,2})?$/.test(raw) || !Number.isFinite(amount) || amount <= 0 || amount > Math.abs(Number(source.entry.amount))) {
ElMessage.warning('退款金额必须大于 0、最多两位小数,且不能超过原消费金额'); return
}
const reason = refundForm.reason.trim()
if (!reason || reason.length > 500) { ElMessage.warning('请填写退款说明(最多 500 字)'); return }
const payload = { amount: amount.toFixed(2), reason }
const signature = JSON.stringify([source.customerId, source.entry.ledgerId, payload])
if (refundAttempt.payload !== signature) refundAttempt = { payload: signature, key: 'finance-refund-' + crypto.randomUUID() }
refundSaving.value = true
refundError.value = null
try {
await refundFinanceConsumption(source.customerId, source.entry.ledgerId, payload, refundAttempt.key)
refundOpen.value = false
ElMessage.success('消费退款已入账')
if (selected.value?.customerId === source.customerId) await refreshWalletDetail()
} catch (error) { refundError.value = getApiErrorDetails(error, '退款失败,请重试') }
finally { refundSaving.value = false }
}
function openAdjustment(account: FinanceAccountSummary, type: 'RECHARGE' | 'DEDUCTION') {
adjustmentAccount.value = account
@@ -425,12 +475,15 @@ async function closeDetail() {
if (route.name === 'finance-customer-account-detail') await router.push({ name: 'finance-customer-accounts' })
}
async function restoreAccountDetail(customerId: string) {
if (!canView.value || detailLoading.value) return
detailLoading.value = true
detailOpen.value = true; editing.value = false; ledgerError.value = null
try {
selected.value = (await listFinanceAccounts({ page: 1, size: 1, customerId })).records.find(row => row.customerId === customerId) || null
if (!selected.value) { ledgerError.value = { message: '未找到该客户账户,可能已停用或无权访问' }; return }
await Promise.all([loadLedger(), loadRechargeRequests()])
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户详情加载失败,请重试') }
finally { detailLoading.value = false }
}
onMounted(() => {
const customerId = String(route.params.customerId || '')
@@ -463,18 +516,43 @@ onBeforeUnmount(clearVoucherPreview)
<template #pagination-left><span>共 {{ total }} 个客户账户</span></template>
</DataTable></section>
</ListPageLayout>
<AdminPage v-else as="div" class="finance-account-detail admin-density" spacing="normal">
<AdminPage v-else as="div" class="finance-account-detail admin-density admin-workbench is-embedded" spacing="normal">
<section class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface">
<DetailPageLayout class="finance-account-detail__layout finance-density-detail" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button></div></div></template>
<template v-if="selected && !ledgerError" #summary><dl class="density-detail-overview account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><dt>实际余额</dt><dd :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }} <small>CNY</small></dd></div><div class="account-metric is-frozen"><dt>冻结金额</dt><dd>{{ money(selected.frozenBalance) }} <small>CNY</small></dd></div><div class="account-metric is-available"><dt>总可用余额</dt><dd>{{ money(selected.availableBalance) }} <small>CNY</small></dd></div><div class="account-metric"><dt>授信额度</dt><dd>{{ money(selected.creditLimit) }} <small>{{ creditExpiryLabel(selected) }}</small></dd></div><div class="account-metric is-frozen"><dt>已用授信</dt><dd>{{ money(selected.creditUsed) }} <small>CNY</small></dd></div><div class="account-metric"><dt>可用授信</dt><dd>{{ money(selected.availableCredit) }} <small>CNY</small></dd></div></dl></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></div></div></template>
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户资金账户余额">
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">实际余额</h2></div>
<div class="workbench-metric-value"><strong :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><LockKeyhole aria-hidden="true" /></span><h2 class="workbench-metric-label">冻结金额</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.frozenBalance) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">总可用余额</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.availableBalance) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">授信额度</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.creditLimit) }}</strong> <span>CNY</span></div><div class="workbench-note" :class="{ 'credit-expired': selected.creditExpiresOn && !selected.creditActive }">{{ creditExpiryLabel(selected) }}</div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">已用授信</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.creditUsed) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">可用授信</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.availableCredit) }}</strong> <span>CNY</span></div>
</article>
</section></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading || detailLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions" :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions account-profile" :column="3"><el-descriptions-item label="客户名称">{{ customerName(selected.customerId, selected.customerName) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false">
<LoadErrorState v-if="rechargeError" title="充值申请加载失败" :message="rechargeError.message" :loading="rechargeLoading" @retry="loadRechargeRequests" />
<div v-else class="detail-table-scroll workbench-table"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
<template #actions="{ row }"><div class="density-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template>
<div v-else class="detail-table-scroll workbench-table is-page-surface"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template><template #reviewedAt="{ row }">{{ row.reviewedAt ? formatDateTime(row.reviewedAt) : '—' }}</template>
<template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template>
<template #voucherFileName="{ row }"><el-button v-if="row.voucherFileId" link type="primary" @click="previewVoucher(row as FinanceRechargeRequest)">{{ row.voucherFileName || '预览付款凭证' }}</el-button><span v-else>—</span></template>
@@ -482,8 +560,7 @@ onBeforeUnmount(clearVoucherPreview)
</DataTable></div>
</FormSection>
<FormSection v-if="!editing" class="ledger-section density-form-section" title="账户流水" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" />
<div class="detail-table-scroll workbench-table"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
<div class="detail-table-scroll workbench-table is-page-surface"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" appearance="integrated" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #filters><QueryToolbar v-model="ledgerFilters" class="ledger-query workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" /></template><template #actions="{ row }"><el-button v-if="canRefund(row as FinanceLedgerEntry)" link type="primary" @click="openRefund(row as FinanceLedgerEntry)">退款</el-button><span v-else>—</span></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span v-if="row.changeType === 'REFUND'">原消费:</span><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
</FormSection>
<FormSection v-else class="term-section density-form-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection>
</template>
@@ -491,6 +568,21 @@ onBeforeUnmount(clearVoucherPreview)
</DetailPageLayout>
</section>
</AdminPage>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="refundOpen" title="原消费退款" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认退款" :confirm-loading="refundSaving" @confirm="submitRefund">
<div class="density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="source" aria-label="原消费资料"><h3 class="density-dialog-section__title">原消费(CNY)</h3><table class="density-table wallet-summary"><tbody>
<tr><th scope="row">原消费流水</th><td>{{ refundSource?.entry.businessNo || refundSource?.entry.ledgerId }}</td></tr>
<tr><th scope="row">关联业务单据</th><td>{{ refundSource?.entry.sourceNo || refundSource?.entry.documentNo || '—' }}</td></tr>
<tr><th scope="row">原消费金额</th><td>{{ money(refundSource?.entry.amount) }}</td></tr>
</tbody></table></section>
<el-alert title="退款会生成独立流水并关联原消费;累计可退金额以服务端校验为准。" type="info" :closable="false" />
<div v-if="refundError"><el-alert :title="refundError.message" type="error" :closable="false" /><ApiErrorMeta :details="refundError" /></div>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="退款登记"><el-form class="density-form" label-position="left" label-width="100px" :disabled="refundSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="退款金额" required><el-input v-model="refundForm.amount" inputmode="decimal" placeholder="请输入退款金额" /></el-form-item></td></tr>
<tr><td><el-form-item label="退款说明" required><el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写退款说明" /></el-form-item></td></tr>
</tbody></table></el-form></section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="adjustmentOpen" :title="`${adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'} · ${adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
<div class="density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前账户资料">
@@ -602,27 +694,26 @@ onBeforeUnmount(clearVoucherPreview)
object-fit: contain;
}
.ledger-section {
padding: var(--admin-space-4) 0 0;
.account-metrics {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: var(--admin-space-5);
padding-block: var(--admin-space-3) 0;
}
.recharge-section {
padding: var(--admin-space-4) 0 0;
border-bottom: 1px solid var(--workbench-border);
.account-metrics .workbench-metric-value > .is-negative,
.account-metrics .credit-expired {
color: var(--el-color-danger);
}
.account-profile :deep(.el-descriptions__table) {
table-layout: fixed;
}
.recharge-table {
min-width: 0;
}
.ledger-query {
margin-bottom: var(--admin-space-3);
}
:deep(.ledger-query.query-toolbar) {
padding: 0;
}
.detail-table-scroll {
overflow-x: auto;
}
@@ -634,9 +725,7 @@ onBeforeUnmount(clearVoucherPreview)
.ledger-identifier,
.ledger-document {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
font-variant-numeric: tabular-nums;
}
@@ -693,12 +782,6 @@ onBeforeUnmount(clearVoucherPreview)
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.account-metric:nth-child(3),
.account-metric:nth-child(5) {
padding-left: 0;
border-left: 0;
}
.finance-account-detail__surface {
width: 100%;
}
@@ -727,16 +810,7 @@ onBeforeUnmount(clearVoucherPreview)
gap: 12px;
}
.account-metric,
.account-metric:first-child,
.account-metric:nth-child(3),
.account-metric:nth-child(5) {
padding-left: 0;
}
.account-metric + .account-metric {
border-left: 0;
}
}
@media (max-width: 480px) {
@@ -127,6 +127,26 @@ describe('FinanceCustomerArapView G2 operations', () => {
}
})
it.each([['PAYABLE_BILLED', '已生成应付单'], ['PARTIALLY_PAID', '部分付款'], ['PAID', '已付款']])(
'shows payable progress and prevents editing occupied or paid sources: %s', async (status, label) => {
const row = { ...feeSummary, direction: 'PAYABLE', sourceSystem: 'FINANCE', sourceType: 'MANUAL',
reviewStatus: 'APPROVED', sourceStatus: status, status, statementGenerated: false } as FinanceFeeSourceSummary
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [row], total: 1, pages: 1 })
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:fee-source:edit'])
await loadRows(wrapper)
const body = wrapper.get('.el-table__body-wrapper')
expect(body.text()).toContain(label)
expect(body.text()).not.toContain('可结算')
expect(body.text()).not.toContain('未生成')
expect(body.findAll('button').map(button => button.text().trim())).toEqual(['详情'])
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, ...row })
await body.get('button').trigger('click')
await flushPromises()
expect(wrapper.get('.arap-metrics').text()).toContain(label)
expect(wrapper.get('.arap-information').text()).toContain('不适用')
},
)
it('shows detail from the list permission but keeps G2 operations independent', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list'])
await loadRows(wrapper)
@@ -253,7 +273,9 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(wrapper.find('.arap-metrics').exists()).toBe(true)
expect(wrapper.findAll('.arap-metric')).toHaveLength(4)
expect(wrapper.find('.arap-information').text()).toContain('单据信息')
expect(wrapper.find('.arap-settlement').text()).toContain('审核与结算')
expect(wrapper.find('.arap-settlement').exists()).toBe(false)
expect(wrapper.find('.arap-information').text()).toContain('已生成对账单')
expect(wrapper.find('.arap-information').text()).toContain('审核原因')
expect(wrapper.find('.operation-log').text()).toContain('操作记录')
expect(wrapper.find('.arap-information').text()).toContain('业务日期')
expect(wrapper.find('.arap-information').text()).not.toContain('T00:00:00')
@@ -262,8 +284,8 @@ describe('FinanceCustomerArapView G2 operations', () => {
it('keeps the fee-source detail readable across the 1920 workspace', () => {
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
expect(page).toContain('width: min(100%, 1920px)')
expect(page).toContain('@media (min-width: 1920px)')
expect(page).toContain('grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr)')
expect(page).toContain('<el-descriptions border :column="3" class="arap-detail density-descriptions">')
expect(page).not.toContain('arap-detail-grid')
expect(page).toContain('finance-density-detail')
expect(page).not.toContain('max-width: 520px')
})
@@ -126,7 +126,7 @@ const queryFields = computed<QueryField[]>(() => [
options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })),
},
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '请选择收付方向', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
{ key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '请选择来源状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '资金账户已结清', value: 'SETTLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
{ key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '请选择来源状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '已生成应付单', value: 'PAYABLE_BILLED' }, { label: '部分付款', value: 'PARTIALLY_PAID' }, { label: '已付款', value: 'PAID' }, { label: '资金账户已结清', value: 'SETTLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '请选择审核状态', options: financeArapStatusOptions },
{ key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '请选择对账单状态', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] },
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
@@ -141,10 +141,17 @@ function closeCustomerSelectOnScroll() {
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG'
}
// Eight fixed fields precede settlement status; optional source fields also occupy a cell.
const settlementStatusSpan = computed(() => {
const source = detail.value
const precedingFields = 8 + Number(source?.direction === 'PAYABLE') + Number(source?.quantity != null) + Number(Boolean(source?.periodStart))
return 3 - precedingFields % 3
})
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', align: 'right', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
{ key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' },
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' },
]
@@ -191,7 +198,7 @@ function businessReference(row: Pick<ArapRow, 'inboundCode' | 'containerNo' | 's
}
function supplierDisplay(row: ArapRow) { return row.direction === 'RECEIVABLE' ? '不适用' : row.supplierName.trim() || '供应商待补充' }
function sourceStatusDisplay(value: unknown) {
const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', SETTLED: '资金账户已结清', VOIDED: '已作废', SUPERSEDED: '已被调整替代' }
const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', PAYABLE_BILLED: '已生成应付单', PARTIALLY_PAID: '部分付款', PAID: '已付款', SETTLED: '资金账户已结清', VOIDED: '已作废', SUPERSEDED: '已被调整替代' }
return labels[String(value || '').toUpperCase()] || '状态待确认'
}
function formatDateTime(value: unknown) {
@@ -414,17 +421,17 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
<template #generated="{ row }">{{ row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
<template #generated="{ row }">{{ row.direction === 'PAYABLE' ? '不适用' : row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
<template #businessDate="{ row }">{{ formatBusinessDate(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
</DataTable></section>
</ListPageLayout>
<AdminPage v-else as="div" class="finance-arap-detail admin-density" spacing="normal">
<AdminPage v-else as="div" class="finance-arap-detail admin-density admin-workbench is-embedded" spacing="normal">
<section class="surface-panel finance-arap-detail__surface workbench-panel is-flush is-page-surface">
<DetailPageLayout class="finance-arap-detail__layout finance-density-detail" aria-label="费用来源详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) || '—' }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><div class="workbench-table"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></div></FormSection></template>
<template v-else-if="detail"><FormSection class="arap-information density-form-section" title="单据信息与审核结算" :bordered="false"><el-descriptions border :column="3" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) || '—' }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单" :span="settlementStatusSpan">{{ detail.direction === 'PAYABLE' ? '不适用' : detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="3">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><div class="workbench-table is-page-surface"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></div></FormSection></template>
</DetailPageLayout>
</section>
</AdminPage>
@@ -522,23 +529,12 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
color: var(--workbench-muted);
}
.arap-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
gap: var(--admin-space-6);
border-bottom: 1px solid var(--workbench-border);
}
.arap-information,
.arap-settlement,
.operation-log {
padding: var(--admin-space-4) 0;
}
.arap-settlement {
padding-left: var(--admin-space-6);
border-left: 1px solid var(--workbench-border);
}
@@ -566,16 +562,6 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
color: var(--workbench-muted);
}
@media (min-width: 1920px) {
.arap-detail-grid {
gap: var(--admin-space-4);
}
.arap-settlement {
padding-left: var(--admin-space-4);
}
}
@media (max-width: 1080px) {
.finance-arap-detail__surface {
@@ -591,16 +577,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
border-left: 0;
}
.arap-detail-grid {
grid-template-columns: 1fr;
gap: 0;
}
.arap-settlement {
padding-left: 0;
border-left: 0;
border-top: 1px solid var(--workbench-border);
}
}
@media (max-width: 720px) {
@@ -108,13 +108,37 @@ describe('FinanceCustomerStatementsView', () => {
expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull()
expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull()
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY')
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(20)
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(22)
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户确认人')
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('2026-08-05 10:00')
expect(document.querySelector('.density-detail-heading')?.textContent).toContain('REC-501')
expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy()
expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy()
expect(document.querySelector('.el-drawer')).toBeNull()
expect(document.querySelector('.statement-detail-page .el-tabs')).toBeNull()
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户A')
expect(wrapper.find('[aria-label="调整记录"]').isVisible()).toBe(true)
expect(wrapper.find('[aria-label="支付历史"]').isVisible()).toBe(true)
})
it('collapses advanced criteria without losing values and resets hidden filters', async () => {
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; appliedFilters: Record<string, unknown> }
const toolbar = () => wrapper.find('#statement-query-filters')
const toggle = () => toolbar().find('button[aria-controls="statement-query-filters"]')
expect(toolbar().findAll('.query-toolbar__field')).toHaveLength(3)
await toggle().trigger('click')
expect(toolbar().findAll('.query-toolbar__field')).toHaveLength(9)
vm.filters.containerNo = 'CONT-31'
vm.filters.createdRange = ['2026-08-01', '2026-08-31']
await toggle().trigger('click')
expect(vm.filters.containerNo).toBe('CONT-31')
const reset = toolbar().findAll('button').find((button) => button.text() === '重置')!
await reset.trigger('click')
await flushPromises()
expect(vm.filters.containerNo).toBe('')
expect(vm.filters.createdRange).toEqual([])
expect(vm.appliedFilters.containerNo).toBe('')
})
it('uses the shared four-group summary grid with long remarks spanning two groups', () => {
@@ -163,9 +187,8 @@ describe('FinanceCustomerStatementsView', () => {
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void>; activeDetailTab: 'lines' | 'payments' }
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }
await vm.openDetail(statement)
vm.activeDetailTab = 'payments'
await flushPromises()
expect(document.querySelector('[aria-label="客户对账单支付历史"]')?.textContent).toContain('PAY-701')
@@ -5,7 +5,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Plus } from '@lucide/vue'
import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -15,6 +15,7 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -65,7 +66,7 @@ const selectedId = ref('')
const detail = ref<FinanceReconciliationDetail | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const activeDetailTab = ref<'lines' | 'payments'>('lines')
const showAdvancedFilters = ref(false)
const detailCache = new Map<string, FinanceReconciliationDetail>()
const createOpen = ref(false)
const createBusy = ref(false)
@@ -118,6 +119,13 @@ const fields = computed<QueryField[]>(() => [
{ key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
])
const visibleFields = computed(() => showAdvancedFilters.value ? fields.value : fields.value.slice(0, 3))
function resetFilters() {
filters.value = { status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [], settled: '', lastPaymentRange: [], createdRange: [] }
void load()
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'status', label: '对账单状态', width: 120, slotName: 'status' },
@@ -126,15 +134,15 @@ const columns: DataTableColumn[] = [
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
{ key: 'periodStart', label: '结算起始日', width: 130 },
{ key: 'periodEnd', label: '结算结束日', width: 130 },
{ key: 'creditDays', label: '账期(天)', width: 100 },
{ key: 'creditDays', label: '账期(天)', align: 'right', width: 100 },
{ key: 'lineCount', label: '费用明细数', width: 110 },
{ key: 'sourceCount', label: '来源数量', width: 100 },
{ key: 'currency', label: '货币', width: 86 },
{ key: 'sourceAmount', label: '调整前金额(CNY)', width: 150 },
{ key: 'adjustmentAmount', label: '调整金额(CNY)', width: 145 },
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 },
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 },
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 },
{ key: 'sourceAmount', label: '调整前金额(CNY)', align: 'right', width: 150 },
{ key: 'adjustmentAmount', label: '调整金额(CNY)', align: 'right', width: 145 },
{ key: 'totalAmount', label: '总应收金额(CNY)', align: 'right', width: 155 },
{ key: 'receivedAmount', label: '已收金额(CNY)', align: 'right', width: 145 },
{ key: 'outstandingAmount', label: '未收金额(CNY)', align: 'right', width: 145 },
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' },
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
{ key: 'remark', label: '备注', minWidth: 180 },
@@ -157,10 +165,10 @@ const detailLineColumns: DataTableColumn[] = [
{ key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' },
{ key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' },
{ key: 'feeItemName', label: '费用项目', minWidth: 160 },
{ key: 'quantity', label: '计费量', width: 110, slotName: 'quantity' },
{ key: 'unitPrice', label: '单价(CNY)', width: 120, slotName: 'unitPrice' },
{ key: 'sourceAmount', label: '来源金额(CNY)', width: 150 },
{ key: 'amount', label: '账单金额(CNY)', width: 150 },
{ key: 'quantity', label: '计费量', align: 'right', width: 110, slotName: 'quantity' },
{ key: 'unitPrice', label: '单价(CNY)', align: 'right', width: 120, slotName: 'unitPrice' },
{ key: 'sourceAmount', label: '来源金额(CNY)', align: 'right', width: 150 },
{ key: 'amount', label: '账单金额(CNY)', align: 'right', width: 150 },
{ key: 'adjustmentReason', label: '调整说明', minWidth: 190 },
{ key: 'billNo', label: '对账单号', minWidth: 190, slotName: 'billNo' },
{ key: 'periodStart', label: '结算起始日', width: 130, slotName: 'periodStart' },
@@ -169,7 +177,7 @@ const detailLineColumns: DataTableColumn[] = [
const paymentColumns: DataTableColumn[] = [
{ key: 'customerName', label: '客户', minWidth: 200, slotName: 'customerName' },
{ key: 'transactionDate', label: '支付日期', width: 130, slotName: 'transactionDate' },
{ key: 'amount', label: '支付金额(CNY)', width: 150 },
{ key: 'amount', label: '支付金额(CNY)', align: 'right', width: 150 },
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' },
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 },
{ key: 'voucherFileIds', label: '收款凭证', minWidth: 130, slotName: 'voucherFileIds' },
@@ -612,9 +620,10 @@ watch(detailRouteId, (statementId) => {
<DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page finance-density-detail workbench-panel is-flush is-page-surface" aria-label="客户对账单详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button><h1 class="density-detail-title">{{ detail?.billNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div></div></template>
<template v-if="detail" #summary>
<dl class="density-detail-overview"><div><dt>客户</dt><dd>{{ customerDisplayName(detail.customerId, detail.customerName) }}</dd></div><div><dt>期间</dt><dd>{{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</dd></div></dl>
<FormSection class="statement-summary-section density-form-section" title="对账单概要" aria-label="对账单摘要">
<dl class="density-fields statement-summary-grid">
<div class="statement-summary-item"><dt>客户</dt><dd>{{ customerDisplayName(detail.customerId, detail.customerName) }}</dd></div>
<div class="statement-summary-item"><dt>期间</dt><dd>{{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</dd></div>
<div class="statement-summary-item"><dt>总应收金额</dt><dd>{{ detail.totalAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>已收金额</dt><dd>{{ detail.paidAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>未收金额</dt><dd class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</dd></div>
@@ -638,12 +647,11 @@ watch(detailRouteId, (statementId) => {
</dl>
</FormSection>
</template>
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs density-element-tabs density-contained-tabs">
<el-tab-pane name="lines"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>账单明细表</span></template>
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
<FormSection class="density-form-section" v-else-if="detail" title="账单明细" :bordered="false">
<div class="detail-table-scroll workbench-table">
<template v-else-if="detail">
<FormSection class="density-form-section" :bordered="false" title="账单明细表" aria-label="账单明细">
<div class="detail-table-scroll workbench-table is-page-surface">
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
<template #direction="{ row }">{{ row.direction === 'PAYABLE' ? '应付' : '应收' }}</template>
<template #sourceType="{ row }">{{ financeSourceTypeLabel(row.sourceType) }}</template>
@@ -658,12 +666,9 @@ watch(detailRouteId, (statementId) => {
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
</DataTable>
</div>
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><div class="workbench-table"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="操作人" min-width="140"><template #default="{ row }">{{ operatorDisplayName(row.operatorName) }}</template></el-table-column><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></div></el-collapse-item></el-collapse>
</FormSection>
</el-tab-pane>
<el-tab-pane name="payments"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>账单支付历史表</span></template>
<FormSection class="density-form-section" v-if="detail" title="支付历史" :bordered="false">
<div class="detail-table-scroll workbench-table">
<FormSection class="density-form-section" :bordered="false" title="账单支付历史表" aria-label="支付历史">
<div class="detail-table-scroll workbench-table is-page-surface">
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
@@ -674,15 +679,27 @@ watch(detailRouteId, (statementId) => {
</DataTable>
</div>
</FormSection>
</el-tab-pane>
</el-tabs>
<FormSection v-if="detail.revisions.length" class="density-form-section" :bordered="false" title="调整记录" aria-label="调整记录">
<div class="workbench-table is-page-surface">
<el-table :data="detail.revisions" size="small" border stripe>
<el-table-column prop="revisionNo" label="修订" width="72" />
<el-table-column label="动作" width="120"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column>
<el-table-column prop="beforeAmount" label="调整前" align="right" width="130" />
<el-table-column prop="afterAmount" label="调整后" align="right" width="130" />
<el-table-column prop="reason" label="原因" min-width="280"><template #default="{ row }"><LineClampText :content="row.reason" :lines="1" /></template></el-table-column>
<el-table-column label="操作人" width="170"><template #default="{ row }"><LineClampText :content="operatorDisplayName(row.operatorName)" /></template></el-table-column>
<el-table-column label="发生时间" width="180"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column>
</el-table>
</div>
</FormSection>
</template>
</DetailPageLayout>
<ListPageLayout v-else aria-label="客户对账单">
<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 v-model="filters" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></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>
@@ -751,12 +768,8 @@ watch(detailRouteId, (statementId) => {
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statement-outstanding { color: var(--el-color-warning-dark-2); }
.statement-detail-tabs { min-width: 0; }
.statement-detail-table { min-width: 0; }
.detail-table-scroll { min-width: 0; overflow-x: auto; }
.statement-line-table { min-width: 1840px; }
.statement-payment-table { min-width: 1300px; }
.revision-collapse { margin-top: var(--admin-space-5); }
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
@media (min-width: 1920px) {
@@ -765,13 +778,6 @@ watch(detailRouteId, (statementId) => {
padding: var(--admin-space-3) var(--admin-space-5);
}
.detail-table-scroll {
margin-bottom: var(--admin-space-3);
}
.revision-collapse {
margin-top: var(--admin-space-3);
}
}
@media (max-width: 640px) {
@@ -6,11 +6,11 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/Fi
const statementsSource = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
describe('Finance formal settlement desktop presentation', () => {
it('keeps the title and actions in the toolbar and settlement totals before results', () => {
it('keeps the title and actions in the toolbar and settlement totals above the list', () => {
expect(source).toContain('<template #toolbar-start>')
expect(source).toContain('<h1>{{ title }}</h1>')
expect(source).toContain('<template #before-results>')
expect(source).toContain('<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">')
expect(source.indexOf('<section class="finance-metric-grid"')).toBeLessThan(source.indexOf('<ListPageLayout :aria-label="title">'))
expect(source).toContain('<section class="finance-metric-grid" aria-label="结算汇总"')
expect(source).toContain('<template #toolbar-actions>')
expect(source).toContain('<div class="settlement-list-actions">')
expect(source).not.toContain('class="settlement-list-toolbar"')
@@ -44,7 +44,7 @@ describe('Finance formal settlement desktop presentation', () => {
it('uses a continuous detail layout with locally scrollable wide tables', () => {
expect(source).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail workbench-panel is-flush is-page-surface"')
expect(source).toContain('class="settlement-detail-content"')
expect(source).toContain('class="detail-table-scroll workbench-table"')
expect(source.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(3)
expect(source).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
expect(source).not.toContain('class="settlement-detail-card"')
})
@@ -5,7 +5,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ArrowLeft, CreditCard, Download, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue'
import { ArrowLeft, CircleCheck, Clock3, CreditCard, Download, HandCoins, Plus, RotateCcw, Wallet, XCircle } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -142,9 +142,9 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
{ key: 'currency', label: '币种', width: 80 },
{ key: 'receivableAmount', label: '应收金额', width: 130, slotName: 'money' },
{ key: 'receivedAmount', label: '已收', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
{ key: 'receivableAmount', label: '应收金额', align: 'right', width: 130, slotName: 'money' },
{ key: 'receivedAmount', label: '已收', align: 'right', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', align: 'right', width: 120, slotName: 'outstandingMoney' },
{ key: 'reconciliationStatementNo', label: '关联对账单', minWidth: 180, slotName: 'reconciliationStatementNo' },
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
@@ -162,9 +162,9 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
{ key: 'currency', label: '币种', width: 80 },
{ key: 'payableAmount', label: '应付金额', width: 130, slotName: 'money' },
{ key: 'paidAmount', label: '已付', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
{ key: 'payableAmount', label: '应付金额', align: 'right', width: 130, slotName: 'money' },
{ key: 'paidAmount', label: '已付', align: 'right', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', align: 'right', width: 120, slotName: 'outstandingMoney' },
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
@@ -520,7 +520,7 @@ onMounted(() => {
</script>
<template>
<AdminPage as="div" class="finance-settlement-page admin-density admin-workbench" spacing="normal">
<AdminPage as="div" class="finance-settlement-page admin-density admin-workbench" :spacing="detailMode ? 'normal' : 'compact'">
<OperationErrorToast v-if="createOpen || paymentOpen" :details="error" />
<OperationErrorToast :message="receiptValidationError" />
<OperationErrorToast :details="receiptError" />
@@ -534,17 +534,25 @@ onMounted(() => {
<el-button v-if="detail && canVoid && detail.status === 'VOIDED'" :loading="actionBusy" @click="performAction(detailActionRow(), 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
</div></div></template>
<template v-if="detail" #summary>
<dl class="density-detail-overview settlement-detail-summary" aria-label="结算金额汇总">
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</dt><dd>{{ money(detail.amount) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</dt><dd class="is-success">{{ money(detail.paidAmount) }}</dd></div>
<div class="summary-item"><dt>未结余额</dt><dd class="is-danger">{{ money(detail.outstandingAmount) }}</dd></div>
<div class="summary-item"><dt>到期日</dt><dd class="is-date">{{ displayDate(detail.dueDate) }}</dd></div>
</dl>
<section class="finance-metric-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"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ money(detail.amount) }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ money(detail.paidAmount) }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">未结余额</h2></div>
<div class="workbench-metric-value"><strong>{{ money(detail.outstandingAmount) }}</strong><span>CNY</span></div>
</article>
</section>
</template>
<LoadErrorState v-if="detailError" class="settlement-detail-state" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading" @retry="loadDetail(String(settlementId || ''))"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" class="settlement-detail-state" :rows="10" animated />
<section v-else-if="detail" class="settlement-detail-content">
<FormSection class="density-form-section" title="基本信息">
<FormSection class="density-form-section" title="基本信息" :bordered="false">
<el-descriptions border :column="4" class="settlement-detail-info density-descriptions">
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
@@ -562,8 +570,8 @@ onMounted(() => {
<el-descriptions-item :label="kind === 'receivable' ? '最后收款时间' : '最后付款时间'">{{ displayDateTime(detail.lastPaymentAt) }}</el-descriptions-item>
</el-descriptions>
</FormSection>
<FormSection class="density-form-section" title="费用明细">
<div class="detail-table-scroll workbench-table">
<FormSection class="density-form-section" title="费用明细" :bordered="false">
<div class="detail-table-scroll workbench-table is-page-surface">
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
<el-table-column prop="feeItemName" label="费用类型" min-width="150" />
<el-table-column prop="sourceType" label="来源类型" min-width="140"><template #default="scope">{{ financeSourceTypeLabel(scope.row.sourceType) }}</template></el-table-column>
@@ -575,8 +583,8 @@ onMounted(() => {
</el-table>
</div>
</FormSection>
<FormSection class="density-form-section" :title="kind === 'receivable' ? '收款记录' : '付款记录'">
<div class="detail-table-scroll workbench-table">
<FormSection class="density-form-section" :title="kind === 'receivable' ? '收款记录' : '付款记录'" :bordered="false">
<div class="detail-table-scroll workbench-table is-page-surface">
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
@@ -590,7 +598,7 @@ onMounted(() => {
</div>
</FormSection>
<FormSection class="density-form-section" title="修订记录" :bordered="false">
<div class="detail-table-scroll workbench-table">
<div class="detail-table-scroll workbench-table is-page-surface">
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无调整记录">
<el-table-column prop="action" label="动作" min-width="120"><template #default="scope">{{ settlementRevisionActionLabel(scope.row.action) || financeOperationActionLabel(scope.row.action) }}</template></el-table-column>
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
@@ -603,7 +611,22 @@ onMounted(() => {
</FormSection>
</section>
</DetailPageLayout>
<ListPageLayout v-else :aria-label="title">
<template v-else>
<section class="finance-metric-grid" aria-label="结算汇总" v-if="!customerError && !error" v-loading="loading" :aria-busy="loading">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.amount) }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '已收' : '已付' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.paid) }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '待收' : '待付' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.outstanding) }}</strong><span>CNY</span></div>
</article>
</section>
<ListPageLayout :aria-label="title">
<section class="workbench-table is-page-surface"><DataTable :preference-key="`finance-settlement-${kind}-table`" 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="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
@@ -614,13 +637,7 @@ onMounted(() => {
<template #notices><template v-if="customerError || error">
<LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState>
</template></template>
<template #before-results>
<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</dt><dd>{{ money(totals.amount) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收' : '已付' }}</dt><dd class="is-success">{{ money(totals.paid) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '待收' : '待付' }}</dt><dd class="is-danger">{{ money(totals.outstanding) }}</dd></div>
</dl>
</template>
<template #toolbar-actions>
@@ -654,6 +671,7 @@ onMounted(() => {
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
</DataTable></section>
</ListPageLayout>
</template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3>
@@ -700,8 +718,8 @@ onMounted(() => {
<tr><th scope="row">当前余额</th><td colspan="3" class="is-danger">{{ money(detail.outstandingAmount) }}</td></tr>
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="收款登记"><h3 class="density-dialog-section__title">收款登记</h3>
<el-form class="settlement-operation-form density-form" label-position="left" label-width="140px" :disabled="receiptBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody>
<section class="density-dialog-section workbench-evidence" aria-label="收款登记"><h3 class="density-dialog-section__title">收款登记</h3>
<el-form class="settlement-operation-form density-form density-form--outlined" label-position="left" label-width="140px" :disabled="receiptBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody>
<tr><td><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入收款金额" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :disabled="receiptBusy" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item></td></tr>
@@ -717,8 +735,7 @@ onMounted(() => {
<style scoped>
.finance-settlement-page { min-width: 0; }
.settlement-summary, .settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; }
.settlement-summary { gap: var(--admin-space-2) 0; }
.settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; }
.settlement-list-actions { flex: 0 0 auto; justify-content: flex-end; gap: var(--admin-space-3); }
.settlement-list-actions :deep(.el-button + .el-button) { margin-left: 0; }
.settlement-detail-page { display: flex; width: min(100%, 1920px); margin: 0 auto; min-width: 0; gap: 0; overflow: hidden; padding: 0; }
@@ -754,21 +771,11 @@ onMounted(() => {
padding: 0 var(--admin-space-5) var(--admin-space-4);
}
.detail-table-scroll {
margin-bottom: var(--admin-space-3);
}
}
@media (max-width: 720px) {
.settlement-detail-summary { grid-template-columns: 1fr; }
.settlement-detail-summary { gap: 10px; padding: 12px 16px; }
.settlement-detail-summary .summary-item { padding: 0; border-left: 0; }
.settlement-detail-content { padding: 0 16px 14px; }
.settlement-detail-state { margin: 14px; }
.settlement-summary { align-items: flex-start; flex-direction: column; gap: 6px; }
.settlement-summary .summary-item + .summary-item { margin-left: 0; padding-left: 0; border-left: 0; }
.settlement-list-actions { width: 100%; justify-content: flex-start; }
.summary-item strong { font-size: 21px; }
.settlement-detail-header { align-items: flex-start; flex-direction: column; padding: 16px; }
.settlement-detail-actions { width: 100%; }
.settlement-detail-actions :deep(.el-button) { min-height: 40px; }
@@ -39,21 +39,21 @@ describe('finance presentation governance', () => {
expect(settlement).toContain('class="settlement-detail-info density-descriptions"')
for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain('title="' + title + '"')
expect(settlement).toContain(':title="kind === \'receivable\' ? \'收款记录\' : \'付款记录\'"')
expect(settlement.match(/class="detail-table-scroll workbench-table"/g)).toHaveLength(3)
expect(settlement.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(3)
expect(settlement).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
expect(settlement).not.toContain('class="detail-section surface-panel"')
expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
expect(settlement.match(/class="settlement-operation-form density-form"/g)).toHaveLength(2)
expect(settlement.match(/class="settlement-operation-form density-form(?: density-form--outlined)?"/g)).toHaveLength(2)
expect(settlement).toContain('density-table density-table--editable settlement-operation-table')
expect(settlement).toContain('.settlement-operation-table td:not([colspan]) { width: 50%; }')
})
it('keeps settlement totals and list actions in the table toolbar', () => {
expect(settlement).toContain('<template #toolbar-start>')
expect(settlement).toContain('class="density-detail-overview settlement-summary" aria-label="结算汇总"')
expect(settlement).toContain('class="finance-metric-grid" aria-label="结算汇总"')
expect(settlement).toContain('<template #toolbar-actions>')
expect(settlement).toContain('class="settlement-list-actions"')
expect(financeDensity).toContain('.settlement-summary.density-detail-overview { padding: 0; border: 0; }')
expect(financeDensity).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))')
expect(settlement).not.toContain('class="settlement-list-toolbar"')
})
@@ -148,15 +148,13 @@ describe('finance presentation governance', () => {
expect(statements).not.toContain('只读展示当前对账单关联的有效收款记录。')
})
it('uses a shared compact financial summary row for customer accounts', () => {
expect(accounts).toContain('class="density-detail-overview account-metrics"')
it('uses shared compact financial cards for customer accounts', () => {
expect(accounts).toContain('class="account-metrics"')
expect(financeDensity).toContain('.detail-page-layout.finance-density-detail { gap: 0; }')
expect(density).toContain('.density-detail-overview')
expect(accounts).toContain(':deep(.ledger-query.query-toolbar)')
expect(accounts).toContain('padding: 0;')
const summaryStyles = density.match(/\.density-detail-overview \{([^}]*)\}/)?.[1]
expect(summaryStyles).toContain('display: flex;')
expect(summaryStyles).toContain('flex-wrap: wrap;')
expect(accounts).toContain('<template #filters><QueryToolbar v-model="ledgerFilters"')
expect(financeDensity).toContain('padding-block: 0;')
expect(accounts.match(/account-metric workbench-metric workbench-tone is-compact/g)).toHaveLength(6)
const queryStyles = workbench.match(/\.query-toolbar\.workbench-query \{([^}]*)\}/)?.[1]
expect(accounts).toContain('class="ledger-query workbench-query"')
expect(queryStyles).toContain('border-color: var(--workbench-border);')
@@ -168,9 +166,11 @@ describe('finance presentation governance', () => {
it('keeps fee-source details continuous without nested metric and section cards', () => {
expect(arap).toContain('class="density-detail-overview arap-metrics"')
expect(arap).toContain('class="arap-information density-form-section"')
expect(arap).toContain('class="arap-settlement density-form-section"')
expect(arap).toContain('padding-left: var(--admin-space-6);')
expect(arap).toContain('border-left: 1px solid')
expect(arap).toContain('title="单据信息与审核结算"')
expect(arap).toContain('label="审核原因" :span="3"')
expect(arap).not.toContain('arap-detail-grid')
expect(arap).not.toContain('arap-settlement')
expect(arap).not.toContain('border-left: 1px solid')
expect(arap).not.toContain('.arap-metric.is-amount { border-color:')
for (const description of ['费用来源与关联业务摘要', '当前审核和出账状态', '按时间查看审核与结算调整记录']) expect(arap).not.toContain('description="' + description + '"')
})
@@ -9,6 +9,9 @@ describe('receivable statistics page', () => {
for (const label of ['统计月份', '应收单位', '应收总额', '已收合计', '余额未收', '打印', '导出Excel']) expect(page).toContain(label)
expect(page).toContain("exportType: 'FINANCE_RECEIVABLE_STATISTICS'")
expect(page).toContain('window.print()')
expect(page).toContain('aria-label="统计月份"')
expect(page).toContain('aria-label="应收单位"')
for (const removed of ['<span>统计月份</span>', '<span>应收单位</span>', '#before-results', '应收款明细', '仅统计CNY正式应收结算单']) expect(page).not.toContain(removed)
expect(page).toContain('displayCustomerName(row)')
expect(page).toContain('/^客户\\s*\\d+$/.test(snapshot)')
expect(page).toContain("return masterName || '—'")
@@ -3,7 +3,7 @@ import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onMounted, ref } from 'vue'
import { Download, Printer, RotateCcw, Search } from '@lucide/vue'
import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -39,9 +39,9 @@ const customerNameById = computed(() => new Map(customers.value.map((item) => [S
const columns: DataTableColumn[] = [
{ key: 'customerName', label: '应收单位', minWidth: 240, slotName: 'customerName' },
{ key: 'currency', label: '币种', width: 100 },
{ key: 'totalReceivable', label: '应收总额(CNY)', width: 180, slotName: 'totalReceivable' },
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, slotName: 'totalReceived' },
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, slotName: 'totalOutstanding' },
{ key: 'totalReceivable', label: '应收总额(CNY)', width: 180, align: 'right', slotName: 'totalReceivable' },
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, align: 'right', slotName: 'totalReceived' },
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, align: 'right', slotName: 'totalOutstanding' },
]
async function load() {
@@ -90,22 +90,31 @@ onMounted(() => { void loadCustomers(); void load() })
</script>
<template>
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="normal">
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="compact">
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
<template v-else>
<section class="finance-metric-grid" aria-label="CNY应收汇总" v-if="!error" v-loading="loading" :aria-busy="loading">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">应收总额</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalReceivable) : '—' }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">已收合计</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalReceived) : '—' }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">余额未收</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalOutstanding) : '—' }}</strong><span>CNY</span></div>
</article>
</section>
<div class="workbench-table is-page-surface"><DataTable class="statistics-table" appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>应收款统计表</h1></template>
<template #filters><section class="query-toolbar statistics-query" aria-label="应收款统计筛选">
<div class="query-field"><span>统计月份</span><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
<div class="query-field"><span>应收单位</span><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div>
<div class="query-field"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
<div class="query-field"><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div>
<div class="query-actions"><el-button type="primary" :loading="loading" @click="search"><Search :size="16" />查询</el-button><el-button @click="reset"><RotateCcw :size="16" />重置</el-button></div>
</section></template>
<template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #before-results><dl class="density-detail-overview statistics-summary" aria-label="CNY应收汇总">
<div><dt>应收总额</dt><dd>CNY {{ money(result?.totalReceivable) }}</dd></div>
<div><dt>已收合计</dt><dd class="is-received">CNY {{ money(result?.totalReceived) }}</dd></div>
<div><dt>余额未收</dt><dd class="is-outstanding">CNY {{ money(result?.totalOutstanding) }}</dd></div>
</dl><div class="statistics-report-heading"><h2>应收款明细</h2><span>共 {{ result?.totalCustomers ?? 0 }} 个应收单位 · 仅统计CNY正式应收结算单</span></div></template>
<template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
<template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
@@ -117,20 +126,17 @@ onMounted(() => { void loadCustomers(); void load() })
<style scoped>
.statistics-query { display: flex; align-items: end; gap: var(--admin-space-5); padding: var(--admin-space-6); }
.query-field { display: grid; min-width: 220px; gap: var(--admin-space-2); color: var(--workbench-muted); font-size: var(--admin-font-body); }
.query-field { min-width: 220px; }
.query-field :deep(.el-select), .query-field :deep(.el-date-editor) { width: 100%; }
.query-actions, .report-actions { display: flex; gap: var(--admin-space-3); }
.report-actions { margin-left: auto; }
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
.outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
.statistics-table { overflow: hidden; }
.statistics-report-heading { display: flex; align-items: center; justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statistics-table h2 { margin: 0; font-size: var(--admin-font-section); }
.statistics-report-heading span { color: var(--workbench-muted); font-size: var(--admin-font-body); }
@media print {
.statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; }
.receivable-statistics-page { gap: 10px; padding: 0; color: #000; }
.workbench-table > :deep(.data-table) { border-radius: var(--admin-radius); box-shadow: none; }
.statistics-summary > div, .statistics-table { border-color: #999; box-shadow: none; }
.finance-metric-grid > article, .statistics-table { border-color: #999; box-shadow: none; break-inside: avoid; }
}
</style>
+4 -4
View File
@@ -434,8 +434,8 @@ onMounted(loadInitialData)
</ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" :disabled="saving">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户基本资料">
<el-form class="density-form density-form--outlined density-dialog-sections" ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" :disabled="saving">
<section class="density-dialog-section workbench-evidence" aria-label="用户基本资料">
<h3 class="density-dialog-section__title">用户资料</h3>
<table class="density-table density-table--editable user-editor-table"><tbody>
<tr><td><el-form-item label="用户名" prop="username" required><span v-if="editingUser">{{form.username || '—'}}</span><el-input v-else v-model="form.username" placeholder="登录用户名" maxlength="64" /></el-form-item></td><td><el-form-item label="姓名" prop="displayName" required><span v-if="editingUser && !canEdit">{{form.displayName || '—'}}</span><el-input v-else v-model="form.displayName" placeholder="真实姓名" maxlength="64" /></el-form-item></td></tr>
@@ -444,7 +444,7 @@ onMounted(loadInitialData)
<tr><td :colspan="editingUser ? 1 : 2"><el-form-item label="状态" prop="status"><span v-if="editingUser && (!canChangeStatus || cannotDisable(editingUser))">{{form.status === 'ACTIVE' ? '正常' : '禁用'}}</span><el-select v-else v-model="form.status"><el-option label="正常" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item></td><td v-if="editingUser"><el-form-item label="最后登录"><span>{{formatLogin(editingUser.lastLoginAt)}}</span></el-form-item></td></tr>
</tbody></table>
</section>
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户作业仓库">
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section workbench-evidence" aria-label="用户作业仓库">
<h3 class="density-dialog-section__title">作业仓库</h3>
<table class="density-table density-table--editable"><tbody>
<tr v-if="hasOverseasPdaRole"><td><el-form-item label="实际海外收货仓" prop="overseasWarehouseId" label-width="145px" required>
@@ -459,7 +459,7 @@ onMounted(loadInitialData)
</el-form-item></td></tr>
</tbody></table>
</section>
<section v-if="!editingUser || canResetPassword" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户登录密码">
<section v-if="!editingUser || canResetPassword" class="density-dialog-section workbench-evidence" aria-label="用户登录密码">
<h3 class="density-dialog-section__title">登录密码 <InlineHelp title="密码要求">密码为 8-64 位,并同时包含字母和数字。</InlineHelp></h3>
<table class="density-table density-table--editable user-editor-table"><tbody>
<tr v-if="editingUser"><td colspan="2"><el-checkbox v-model="form.resetPassword">同时重置密码</el-checkbox></td></tr>
@@ -85,14 +85,15 @@ describe('PermissionsView IAM mode', () => {
it('keeps the shared status badge prop defined during table slot initialization', () => {
expect(permissionsSource).toContain("(row as Permission).status || '—'")
})
it('uses outlined business action buttons while keeping tree view helpers as links', () => {
it('uses outlined business action buttons without global tree view helpers', () => {
expect(permissionsSource).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(permissionsSource).toContain("{ key: 'add-child', label: '添加下级', priority: 400")
expect(permissionsSource).toContain("{ key: 'edit', label: '编辑', priority: 300")
expect(permissionsSource).toContain("{ key: 'status', label: row.status === '启用' ? '停用' : '启用'")
expect(permissionsSource).toContain("{ key: 'delete', label: '删除', tone: 'danger', divided: true")
expect(permissionsSource).toContain('<RowActions text-actions v-if="permissionActions(row as Permission).some')
expect(permissionsSource).toContain('<el-button link type="primary" @click="expandAllRows">展开全部</el-button>')
expect(permissionsSource).not.toContain('展开全部')
expect(permissionsSource).not.toContain('收起操作权限')
expect(permissionsSource).not.toContain('canAddChild(row as Permission)" link')
expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link')
expect(permissionsSource).not.toContain('canChangeStatusRow(row as Permission)" link')
@@ -103,7 +104,10 @@ describe('PermissionsView IAM mode', () => {
})
it('keeps technical permission fields out of the default list and labels the table', () => {
expect(permissionsSource).toContain("{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' }")
expect(permissionsSource).not.toContain("key: 'code', label: '权限编码'")
expect(permissionsSource).not.toContain('<FolderTree')
expect(permissionsSource).not.toContain('<ShieldCheck')
expect(permissionsSource).not.toContain('<Braces')
expect(permissionsSource).toContain("{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' }")
expect(permissionsSource).toContain("{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' }")
expect(permissionsSource).toContain('aria-label="平台权限资源列表"')
+13 -28
View File
@@ -4,7 +4,7 @@ import '@/styles/adminDensity.css'
import '../iamDensity.css'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { FolderTree, Plus, ShieldCheck } from '@lucide/vue'
import { Plus } from '@lucide/vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -90,9 +90,10 @@ const parentOptions = computed(() => permissions.value.filter((item) => (
['目录', '菜单', '页面'].includes(item.type) && !blockedParentIds.value.has(item.id)
)))
const permissionColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions', className: 'permission-actions-column' },
{ key: 'name', label: '权限名称', minWidth: 260, slotName: 'name' },
{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' },
{ key: 'menuCount', label: '菜单数', width: 90, align: 'right', formatter: (row: PermissionTreeNode) => permissionCounts(row).menuCount },
{ key: 'actionCount', label: '操作数', width: 90, align: 'right', formatter: (row: PermissionTreeNode) => permissionCounts(row).actionCount },
{ key: 'type', prop: 'type', label: '类型', width: 80 },
{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' },
{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' },
@@ -128,25 +129,16 @@ function collectDescendantIds(nodes: PermissionTreeNode[], ids: string[] = []) {
return ids
}
function permissionSummary(row: PermissionTreeNode) {
function permissionCounts(row: PermissionTreeNode) {
const descendants = collectDescendantIds(row.children ?? [])
.map((id) => permissions.value.find((item) => item.id === id))
.filter((item): item is Permission => Boolean(item))
const menuCount = descendants.filter((item) => ['菜单', '页面'].includes(item.type)).length
const actionCount = descendants.filter((item) => ['按钮', '接口'].includes(item.type)).length
if (menuCount === 0 && actionCount === 0) return '无下级权限'
return [menuCount ? `${menuCount} 个菜单` : '', actionCount ? `${actionCount} 项操作` : ''].filter(Boolean).join(' · ')
return { menuCount, actionCount }
}
function expandAllRows() {
expandedRowKeys.value = collectDescendantIds(treeRows.value)
tableRenderKey.value += 1
}
function collapseActionRows() {
expandedRowKeys.value = rootRowKeys.value
tableRenderKey.value += 1
}
function openCreate(parentId: string | null = null) {
Object.assign(form, emptyForm(), { parentId })
@@ -235,7 +227,6 @@ async function removePermission(row: Permission) {
rowActionId.value = ''
}
}
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '页面': 'menu', '按钮': 'action', '接口': 'api' }[type] }
function canAddChild(row: Permission) {
return canCreate.value && ['菜单', '页面'].includes(row.type)
@@ -287,13 +278,11 @@ onMounted(loadPermissions)
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template></template>
<template #before-results><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span><span class="permission-protection-hint">受保护资源不可编辑、停用或删除</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template>
<template #actions="{ row }"><RowActions text-actions v-if="permissionActions(row as Permission).some(action => action.visible !== false)" :actions="permissionActions(row as Permission)" :more-label="moreActionsLabel(row as Permission)" @action="handlePermissionAction($event, row as Permission)" /><span v-else class="secondary-text">仅查看</span></template>
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="['目录', '菜单', '页面'].includes((row as Permission).type)" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
<template #name="{ row }"><strong>{{ (row as Permission).name }}</strong></template>
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
<template #status="{ row }"><StatusBadge :status="(row as Permission).status || '—'" /></template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 项</span></template>
@@ -317,23 +306,19 @@ onMounted(loadPermissions)
</template>
<style scoped>
:deep(td.permission-actions-column > .cell) { display: flex; align-items: center; white-space: nowrap; }
:deep(td.permission-actions-column > .cell > .row-actions) { display: inline-flex; flex-wrap: nowrap; flex-shrink: 0; }
:deep(td.permission-actions-column > .cell > .el-table__indent),
:deep(td.permission-actions-column > .cell > .el-table__expand-icon),
:deep(td.permission-actions-column > .cell > .el-table__placeholder) { flex-shrink: 0; }
.permission-editor-table { table-layout: fixed; }
.permission-editor-table td:not([colspan="2"]) { width: 50%; }
.permission-tree-tools { display: flex; align-items: center; gap: var(--admin-space-6); }
.permission-protection-hint { color: var(--workbench-muted); }
.tree-view-actions { display: flex; align-items: center; gap: var(--admin-space-1); white-space: nowrap; }
.tree-view-actions .el-button + .el-button { margin-left: 0; }
.permission-name > div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.permission-name small { color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 1.35; }
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
@media (max-width: 720px) {
.permission-editor-table tr { display: grid; }
.permission-editor-table td:not([colspan="2"]) { width: auto; }
.permission-tree-tools { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
}
@media (max-width: 420px) {
.tree-view-actions { width: 100%; justify-content: flex-start; }
}
</style>
@@ -68,7 +68,7 @@ beforeEach(() => {
describe('roles and permission dialogs', () => {
it('keeps saved and hidden formal grants across fullscreen and excludes navigation group IDs', async () => {
const wrapper = await roleEditor(); const dialog = wrapper.findComponent(AppDialog); const tree = wrapper.findComponent(ElTree)
expect(dialog.props()).toMatchObject({ title: '配置角色 · TEST_OPERATOR', width: 'min(860px, calc(100vw - 28px))', fullscreenable: true })
expect(dialog.props()).toMatchObject({ title: '配置角色 · 测试角色', width: 'min(860px, calc(100vw - 28px))', fullscreenable: true })
expect(document.querySelector('.el-dialog [placeholder="如:ORDER_OPERATOR"]')).toBeNull()
expect(tree.vm.getCheckedKeys()).toContain(childId)
expect(tree.vm.getNode(hiddenId)).toBeTruthy()
+2 -2
View File
@@ -117,11 +117,11 @@ describe('RolesView', () => {
expect(rolesSource).toContain('aria-label="平台角色列表"')
})
it('uses the prototype-sized centered role editor with responsive split panes', () => {
it('uses the prototype-sized centered role editor with stacked information and permissions', () => {
expect(rolesSource).toContain('<AppDialog v-model="editorOpen"')
expect(rolesSource).toContain('width="min(860px, calc(100vw - 28px))"')
expect(rolesSource).not.toContain('<AppDrawer v-model="editorOpen"')
expect(rolesSource).toContain('grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr)')
expect(rolesSource).not.toContain('grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr)')
expect(rolesSource).toContain('.role-editor-layout { grid-template-columns: minmax(0, 1fr); }')
expect(rolesSource).toContain('fullscreenable :confirm-loading="saving"')
expect(rolesSource).toContain(':global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen))')
+12 -10
View File
@@ -63,7 +63,7 @@ const formRules: FormRules = {
name: [{ required: true, whitespace: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, whitespace: true, message: '请输入角色编码', trigger: 'blur' }],
}
const editorLabel = computed(() => form.id ? `配置角色 · ${form.code.trim() || form.name.trim() || '—'}` : '新增角色')
const editorLabel = computed(() => form.id ? `配置角色 · ${form.name.trim() || '—'}` : '新增角色')
const canEdit = computed(() => access.can('platform:role:edit'))
const canGrant = computed(() => access.can('platform:role:grant'))
const requiredPermission = computed(() => form.id && canEdit.value ? 'platform:role:edit' : form.id ? 'platform:role:grant' : 'platform:role:create')
@@ -263,13 +263,13 @@ onMounted(loadRoles)
</ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
<el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="90px" class="density-form density-dialog-sections role-editor-layout" :disabled="saving">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="角色基本资料">
<el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="90px" class="density-form density-form--outlined density-dialog-sections role-editor-layout" :disabled="saving">
<section class="density-dialog-section workbench-evidence" aria-label="角色基本资料">
<h3 class="density-dialog-section__title">基本信息</h3>
<table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="角色名称" prop="name" required><span v-if="form.id && !canEdit">{{form.name || '—'}}</span><el-input v-else v-model="form.name" placeholder="如:订单运营专员" maxlength="64" /></el-form-item></td></tr>
<tr><td><el-form-item label="角色编码" prop="code" required><span v-if="form.id">{{form.code || '—'}}</span><el-input v-else v-model="form.code" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></td></tr>
<tr><td><el-form-item label="角色说明"><span v-if="form.id && !canEdit">{{form.description || '—'}}</span><el-input v-else v-model="form.description" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></td></tr>
<table class="density-table density-table--editable role-basic-table"><tbody>
<tr><td :colspan="form.id ? 2 : 1"><el-form-item label="角色名称" prop="name" required><span v-if="form.id && !canEdit">{{form.name || '—'}}</span><el-input v-else v-model="form.name" placeholder="如:订单运营专员" maxlength="64" /></el-form-item></td>
<td v-if="!form.id"><el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="角色说明"><span v-if="form.id && !canEdit">{{form.description || '—'}}</span><el-input v-else v-model="form.description" type="textarea" :rows="2" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></td></tr>
</tbody></table>
</section>
<section v-if="canGrant" class="role-editor-permissions density-dialog-section" data-tone="source" aria-label="角色菜单与操作权限">
@@ -282,10 +282,12 @@ onMounted(loadRoles)
</template>
<style scoped>
.role-editor-layout { grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr); align-items: start; }
.role-editor-layout:not(:has(.role-editor-permissions)) { grid-template-columns: minmax(0, 1fr); }
.role-editor-layout { grid-template-columns: minmax(0, 1fr); }
.role-basic-table { table-layout: fixed; }
.role-basic-table td:not([colspan]) { width: 50%; }
.role-editor-permissions .permission-tree { max-height: min(320px, 35vh); overflow: auto; overscroll-behavior: contain; }
.permission-node span { min-width: 0; }
.permission-node.is-navigation-group span { font-weight: 650; }
:global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen)) { max-height: min(760px, calc(100vh - 32px)); }
@media (max-width: 720px) { .role-editor-layout { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 720px) { .role-basic-table td { display: block; width: 100%; box-sizing: border-box; } }
</style>
+1 -1
View File
@@ -22,7 +22,7 @@
## 关键边界
- 采购样品履约是一条独立链路:下单、包装、面单、交寄、国内核费、寄客户;改节点或费用前先读 `docs/海外仓业务/采购订单样品履约流程变更需求.md`,不套用普通头程或尾程流程。
- 采购按 `baiyue-firstleg/lastleg-service/docs/procurement-workflow.md` 的七状态流程执行:审核、分批收货、客户确认、分批出库及签收扣采购库存;费用复用海外费用窗口,默认余额支付,不恢复旧两段样品节点。
- 本土卡车与散板是海外本地运输业务,区别于国内订舱拖车与客户排车;车型、司机与车队主数据归 `overseas-master-data`,本模块只消费。
- 采购费用入海外费用池后由 `finance/` 记账,本模块不判定收付状态,不重复写入费用行。
- 列表与表单的客户、仓库、供应商候选一律取服务端结果,不从本地或名称推算;长整型 ID 按字符串透传。
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const client = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
vi.mock('@/shared/api/http', async original => ({ ...await original<typeof import('@/shared/api/http')>(), http: client }))
import { actOnOverseasFee, createOverseasFee, getOverseasFee, listBusinessFeeOptions, listBusinessOverseasFees, listOverseasFeeItems, listOverseasFees, listOverseasFeeRetries, type SaveOverseasFee } from './overseasFees'
const base = '/platform/v1/last-leg/finance-fee-sources'
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
const row = { id: '9007199254740993', sourceFeeId: '9007199254740993', customerId: '1', businessId: '9007199254740995', direction: 'RECEIVABLE', allowedActions: ['APPROVE'] }
describe('海外费用真实接口', () => {
beforeEach(() => vi.clearAllMocks())
it('分页不退回旧非分页接口,长整型身份保持字符串', async () => {
client.get.mockResolvedValue(envelope({ records: [row], total: '1', pages: '1', page: 1, size: 20 }))
const result = await listOverseasFees({ page: 1, size: 20, feeItemCode: 'F1' })
expect(client.get).toHaveBeenCalledWith(`${base}/page`, { params: { page: 1, size: 20, feeItemCode: 'F1' } })
expect(result.records[0]?.businessId).toBe('9007199254740995')
expect(result.total).toBe(1)
client.get.mockResolvedValue(envelope(row))
expect((await getOverseasFee(row.sourceFeeId)).sourceFeeId).toBe(row.sourceFeeId)
})
it('创建传正式业务订单及幂等键,审核和锁款重试各用正式命令', async () => {
client.post.mockResolvedValue(envelope(row))
const payload = { businessNo: 'WT1', businessType: 'WAREHOUSE_TRANSFER', amount: 10 } as SaveOverseasFee
await createOverseasFee(payload, 'create-1')
expect(client.post).toHaveBeenCalledWith(base, payload, { headers: { 'Idempotency-Key': 'create-1' } })
await actOnOverseasFee(row.sourceFeeId, 'REJECT', { decision: 'REJECT', reason: '重复费用', idempotencyKey: 'review-1' })
expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/review`, { decision: 'REJECT', reason: '重复费用', idempotencyKey: 'review-1' })
await actOnOverseasFee(row.sourceFeeId, 'RETRY_FREEZE', { idempotencyKey: 'retry-1' })
expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/retry-funding`, { idempotencyKey: 'retry-1' })
await actOnOverseasFee(row.sourceFeeId, 'RETRY_PUBLISH', { expectedVersion: 1, eventId: 'original-event', idempotencyKey: 'retry-2' })
expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/retry-delivery`, { expectedVersion: 1, eventId: 'original-event', idempotencyKey: 'retry-2' })
})
it('目录和业务订单选项读取真实数据,不套用人民币默认价', async () => {
client.get.mockResolvedValueOnce(envelope([{ code: 'F1', name: '操作费', defaultPrice: 10 }]))
.mockResolvedValueOnce(envelope([{ businessId: '9007199254740995', customerId: '1', orderId: null, warehouseId: '2' }]))
expect(await listOverseasFeeItems()).toEqual([{ feeCode: 'F1', feeTypeName: '操作费', defaultPrice: 10 }])
expect(client.get).toHaveBeenLastCalledWith(`${base}/fee-items`, { params: { direction: 'RECEIVABLE' } })
expect((await listBusinessFeeOptions({ businessType: 'WAREHOUSE_TRANSFER', keyword: 'WT' }))[0]?.businessId).toBe('9007199254740995')
expect(client.get).toHaveBeenLastCalledWith(`${base}/business-options`, { params: { businessType: 'WAREHOUSE_TRANSFER', keyword: 'WT' } })
})
it('应付项目明确传递方向', async () => {
client.get.mockResolvedValue(envelope([]))
await listOverseasFeeItems('PAYABLE')
expect(client.get).toHaveBeenCalledWith(`${base}/fee-items`, { params: { direction: 'PAYABLE' } })
})
it('按稳定费用身份查询完整重试记录', async () => {
client.get.mockResolvedValue(envelope([]))
expect(await listOverseasFeeRetries(row.sourceFeeId)).toEqual([])
expect(client.get).toHaveBeenCalledWith(`${base}/${row.sourceFeeId}/retries`)
})
it('单据费用先严格匹配客户、类型及ID,合并去重后分页', async () => {
const business = { businessId: row.businessId, businessType: 'LASTLEG_ORDER', businessNo: 'TB1', customerId: '1', customerName: '测试客户', orderId: null, warehouseId: null }
const related = { ...business, businessId: 'return-1', businessType: 'RETURN_ORDER' }
const first = { ...row, businessType: business.businessType, createdAt: '2026-10-22T10:00:00' }
const second = { ...first, sourceFeeId: '9007199254740994', businessId: related.businessId, businessType: related.businessType }
client.get.mockResolvedValue(envelope([first, second, first,
{ ...first, sourceFeeId: 'wrong-id', businessId: 'another' },
{ ...first, sourceFeeId: 'wrong-type', businessType: 'RETURN_ORDER' },
{ ...first, sourceFeeId: 'wrong-customer', customerId: '2' },
]))
const result = await listBusinessOverseasFees([business, related], { page: 2, size: 1 })
expect(client.get).toHaveBeenCalledWith(base, { params: { customerId: '1', businessType: 'LASTLEG_ORDER', businessNo: 'TB1' } })
expect(result).toMatchObject({ total: 2, pages: 2, page: 2, size: 1 })
expect(result.records.map(fee => fee.sourceFeeId)).toEqual([first.sourceFeeId])
})
it.each(['RECEIVABLE', 'PAYABLE'] as const)('混合返回时先按 %s 分开,再统计分页,不遗漏第二页数据', async direction => {
const business = { businessId: row.businessId, businessType: 'LASTLEG_ORDER', businessNo: 'TB1', customerId: '1', customerName: '客户', orderId: null, warehouseId: null }
const first = { ...row, businessType: 'LASTLEG_ORDER', direction, createdAt: '2026-10-23T10:00:00' }
const second = { ...first, sourceFeeId: 'second', createdAt: '2026-10-23T11:00:00' }
const opposite = { ...first, sourceFeeId: 'opposite', direction: direction === 'RECEIVABLE' ? 'PAYABLE' : 'RECEIVABLE' }
client.get.mockResolvedValue(envelope([first, opposite, second, { ...first, sourceFeeId: 'unknown', direction: null }]))
const result = await listBusinessOverseasFees(business, { page: 2, size: 1 }, direction)
expect(result).toMatchObject({ total: 2, pages: 2 })
expect(result.records.map(fee => fee.sourceFeeId)).toEqual([first.sourceFeeId])
expect(result.records.every(fee => fee.direction === direction)).toBe(true)
})
})
@@ -0,0 +1,99 @@
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 OverseasFee {
storageRent?: { outboundNo: string; outboundAt: string; calculatedAmount: number; chargedAmount: number | null;
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
sourceType: string; businessType: string; businessId: string | null; businessNo: string
displayBusinessType?: string
direction: OverseasFeeDirection
supplierId?: string | null; supplierName?: string | null; supplierCategory?: string | null
settlementMode?: 'BILL' | 'WALLET' | 'ON_ACCOUNT' | 'DIRECT_PAID' | null
paidAt?: string | null; paymentMethod?: string | null; paymentReferenceNo?: string | null; paymentVoucherFileIds?: string[] | null
orderId: string | null; warehouseId: string | null; feeItemCode: string; feeItemName: string
feeRemark?: string | null
businessDate: string; currency: string; amount: number; originalCurrency: string; originalAmount: number
exchangeRate?: number | null; exchangeRateMonth?: string | null
chargeableQuantity: number | null; quantityUnit: string | null; unitPrice: number | null
periodStart: string | null; periodEnd: string | null; reviewStatus: string; sourceStatus: string
publicationStatus: string; collectionMode: 'BILL' | 'WALLET'; walletStatus: string | null
deliveryStatus?: 'PENDING' | 'PROCESSING' | 'PUBLISHED' | 'DEAD' | null
deliveryKind?: 'FEE' | 'FUNDING' | null; deliveryEventId?: string | null; deliveryErrorMessage?: string | null
walletErrorMessage: string | null; reviewReason: string | null; reviewerId?: string | null; reviewerName?: string | null; reviewedAt: string | null
createdAt: string; updatedAt: string; allowedActions: string[]
}
export interface BusinessFeeOption {
businessId: string; businessType: string; businessNo: string; customerId: string; customerName: string
warehouseId: string | null; orderId: string | null; orderType?: string
}
export interface SaveOverseasFee {
customerId: string; customerName: string; sourceType: string; businessType: string
businessId: string; businessNo: string; orderId: string | null; warehouseId: string | null
feeItemCode: string; feeItemName: string; direction: OverseasFeeDirection; businessDate: string
feeRemark?: string | null
supplierId?: string | null; supplierName?: string | null; supplierCategory?: string | null
settlementMode?: 'BILL' | 'WALLET' | 'ON_ACCOUNT' | 'DIRECT_PAID' | null
paidAt?: string | null; paymentMethod?: string | null; paymentReferenceNo?: string | null; paymentVoucherFileIds?: string[] | null
currency: 'CNY' | 'USD'; amount: number
chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number
}
const base = '/platform/v1/last-leg/finance-fee-sources'
export interface OverseasFeeRetryRecord {
requestId: string; requestedAt: string; operatorName: string; action: string
result: 'SUCCESS' | 'FAILED' | 'SUBMITTED'; errorMessage: string | null
}
export async function listOverseasFeeRetries(id: string): Promise<OverseasFeeRetryRecord[]> {
return unwrapApiResponse(await http.get<ApiEnvelope<OverseasFeeRetryRecord[]>, ApiEnvelope<OverseasFeeRetryRecord[]>>(`${base}/${encodeURIComponent(id)}/retries`))
}
export async function listOverseasFeeItems(direction: 'RECEIVABLE' | 'PAYABLE' = 'RECEIVABLE') {
const response = await http.get<ApiEnvelope<Array<{ code: string; name: string; defaultPrice: number | null }>>, ApiEnvelope<Array<{ code: string; name: string; defaultPrice: number | null }>>>(`${base}/fee-items`, { params: { direction } })
return unwrapApiResponse(response).map(item => ({ feeCode: item.code, feeTypeName: item.name, defaultPrice: item.defaultPrice }))
}
export async function listOverseasFeeCustomers() {
const response = await http.get<ApiEnvelope<Array<{ customerId: string; customerName: string }>>, ApiEnvelope<Array<{ customerId: string; customerName: string }>>>(`${base}/customer-options`)
return unwrapApiResponse(response).map(row => ({ ...row, customerId: String(row.customerId) }))
}
function normalizeFee(value: OverseasFee): OverseasFee {
return { ...value, id: String(value.id), sourceFeeId: String(value.sourceFeeId), customerId: String(value.customerId),
businessId: value.businessId == null ? null : String(value.businessId), orderId: value.orderId == null ? null : String(value.orderId),
warehouseId: value.warehouseId == null ? null : String(value.warehouseId), allowedActions: value.allowedActions || [] }
}
export async function listOverseasFees(query: Record<string, string | number | undefined>): Promise<FeePage<OverseasFee>> {
const response = await http.get<ApiEnvelope<FeePage<OverseasFee>>, ApiEnvelope<FeePage<OverseasFee>>>(`${base}/page`, { params: query })
const data = unwrapApiResponse(response)
return { ...data, records: data.records.map(normalizeFee), total: Number(data.total), pages: Number(data.pages) }
}
export async function listBusinessOverseasFees(business: BusinessFeeOption | BusinessFeeOption[], pagination: { page: number; size: number }, direction: OverseasFeeDirection = 'RECEIVABLE'): Promise<FeePage<OverseasFee>> {
const { page, size } = pagination
const scopes = Array.isArray(business) ? business : [business]
if (!scopes.length || scopes.some(scope => !scope.businessId || !scope.customerId || !scope.businessType)) throw new Error('业务单据信息不完整')
// 非分页接口返回两种方向;先按客户、业务身份及收付方向匹配,再合并分页。
const groups = await Promise.all(scopes.map(async scope => {
const response = await http.get<ApiEnvelope<OverseasFee[]>, ApiEnvelope<OverseasFee[]>>(base, {
params: { customerId: scope.customerId, businessType: scope.businessType, businessNo: scope.businessNo },
})
return unwrapApiResponse(response).map(normalizeFee).filter(row => row.customerId === scope.customerId && row.businessType === scope.businessType && row.businessId === scope.businessId && row.direction === direction)
}))
const records = [...new Map(groups.flat().map(row => [row.sourceFeeId, row])).values()]
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.sourceFeeId.localeCompare(left.sourceFeeId))
return { records: records.slice((page - 1) * size, page * size), total: records.length, pages: Math.ceil(records.length / size), page, size }
}
export async function getOverseasFee(id: string) {
return normalizeFee(unwrapApiResponse(await http.get<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(`${base}/${encodeURIComponent(id)}`)))
}
export async function listBusinessFeeOptions(params: { businessType: string; keyword?: string; customerId?: string }) {
const response = await http.get<ApiEnvelope<BusinessFeeOption[]>, ApiEnvelope<BusinessFeeOption[]>>(`${base}/business-options`, { params })
return unwrapApiResponse(response).map(row => ({ ...row, businessId: String(row.businessId), customerId: String(row.customerId), orderId: row.orderId == null ? null : String(row.orderId), warehouseId: row.warehouseId == null ? null : String(row.warehouseId) }))
}
export async function createOverseasFee(payload: SaveOverseasFee, key: string) {
return normalizeFee(unwrapApiResponse(await http.post<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(base, payload, { headers: { 'Idempotency-Key': key } })))
}
export async function actOnOverseasFee(id: string, action: string, body: Record<string, unknown>) {
const endpoint = action === 'APPROVE' || action === 'REJECT' ? 'review'
: ['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action) ? 'retry-delivery'
: action.startsWith('RETRY_') ? 'retry-funding' : action.toLowerCase()
return normalizeFee(unwrapApiResponse(await http.post<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(`${base}/${encodeURIComponent(id)}/${endpoint}`, body)))
}
@@ -57,8 +57,8 @@ export async function listOverseasPurchaseCustomerOptions(keyword = '', limit =
const response = await http.get<ApiEnvelope<OverseasPurchaseCustomerOption[]>, ApiEnvelope<OverseasPurchaseCustomerOption[]>>(`${procurementBasePath}/customer-options`, { params: { keyword: keyword.trim() || undefined, limit } })
return unwrapApiResponse(response)
}
export async function listOverseasPurchaseWarehouseOptions() {
const response = await http.get<ApiEnvelope<OverseasPurchaseWarehouseOption[]>, ApiEnvelope<OverseasPurchaseWarehouseOption[]>>(`${procurementBasePath}/warehouse-options`)
export async function listOverseasPurchaseWarehouseOptions(procurementDirection?: import('../procurementDirection').ProcurementDirection) {
const response = await http.get<ApiEnvelope<OverseasPurchaseWarehouseOption[]>, ApiEnvelope<OverseasPurchaseWarehouseOption[]>>(`${procurementBasePath}/warehouse-options`, { params: { procurementDirection } })
return unwrapApiResponse(response).map((item) => ({ ...item, warehouseId: String(item.warehouseId) }))
}
export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) {
@@ -3,6 +3,7 @@ import type { ProcurementFile, ProcurementFileCategory, ProcurementRemainingKind
export type ProcurementCommand = 'review' | 'purchase' | 'purchase/confirm' | 'budget-authorizations' | 'planned-warehouse' | 'cancel' | 'overseas-receipts' | 'domestic-receipts' | 'overseas-receipts/complete' | 'domestic-receipts/complete' | 'packing' | 'packing/confirm' | 'packing/withdraw' | 'dispositions' | 'receipts/correct' | 'dispositions/revoke' | 'labels' | 'international-estimate' | 'labels/apply' | 'domestic-repack' | 'overseas-shipment' | 'customer-shipment' | 'charges/confirm' | 'fees/retry' | 'fees/terminate' | 'fees/release-estimate' | 'fees/correct-target' | 'fees/refund' | 'fees/replace-failed' | 'fees/additional' | 'control-notes' | 'control-notes/resolve' | 'actual-warehouse/correct' | 'exception-close'
export interface ProcurementCommandBody {
procurementDirection?: import('../procurementDirection').ProcurementDirection
name?: string
targetType?: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; targetId?: string; blocking?: boolean; clientSummary?: string; noteId?: string
resolutions?: ProcurementResolution[]; customerVoucherFileId?: string; financialConclusion?: string
@@ -0,0 +1,14 @@
import { beforeEach, expect, it, vi } from 'vitest'
const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
vi.mock('@/shared/api/http', async importOriginal => ({ ...await importOriginal<typeof import('@/shared/api/http')>(), http: api }))
import { getProcurementWorkflow, submitProcurementWorkflow } from './procurementWorkflow'
beforeEach(() => { vi.clearAllMocks(); api.get.mockResolvedValue({ code: '200', data: { orderNo: 'OP/1' } }); api.post.mockResolvedValue({ code: '200', data: { orderNo: 'OP/1' } }) })
it('scopes workflow and every batch operation to the exact order and customer', async () => {
await getProcurementWorkflow('OP/1', '1001')
expect(api.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/workflow', { params: { customerId: '1001' } })
for (const action of ['receipts', 'request-confirmation', 'outbounds', 'outbounds/9007199254740993/dispatch', 'outbounds/9007199254740993/complete']) {
const command = { version: 3, idempotencyKey: 'retry-same-key' }
await submitProcurementWorkflow('OP/1', '1001', action, command)
expect(api.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/overseas-procurements/OP%2F1/${action}`, command, { params: { customerId: '1001' } })
}
})
@@ -0,0 +1,16 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface ProcurementWorkflowReceipt { id: string; quantity: number; fileIds: string[]; receivedAt: string }
export interface ProcurementOutbound { id: string; outboundNo: string; quantity: number; trackingNo: string; status: string; fileIds: string[]; createdAt: string; completedAt: string | null }
export interface ProcurementWorkflow {
orderNo: string; version: number; status: string; receivedQuantity: number; stockQuantity: number; reservedQuantity: number; availableQuantity: number
receipts: ProcurementWorkflowReceipt[]; outbounds: ProcurementOutbound[]
}
export interface WorkflowCommand { version: number; idempotencyKey: string; quantity?: number; trackingNo?: string; fileIds?: string[] }
const base = '/platform/v1/last-leg/overseas-procurements'
export async function getProcurementWorkflow(orderNo: string, customerId: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/workflow`, { params: { customerId } }))
}
export async function submitProcurementWorkflow(orderNo: string, customerId: string, action: string, payload: WorkflowCommand) {
return unwrapApiResponse(await http.post<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/${action}`, payload, { params: { customerId } }))
}
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const client = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('@/shared/api/http', async original => ({ ...await original<typeof import('@/shared/api/http')>(), http: client }))
import { getStorageRentLot, listStorageRentLots, listStorageRentWarehouses } from './storageRent'
const base = '/platform/v1/last-leg/storage-rent'
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
const lot = { id: '9223372036854775001', customerId: '9223372036854775002', warehouseId: '9223372036854775003', businessNo: 'WT1' }
describe('仓租查询接口', () => {
beforeEach(() => vi.clearAllMocks())
it('uses the paginated root endpoint with server filters and preserves long IDs', async () => {
client.get.mockResolvedValue(envelope({ records: [lot], total: '1', page: 1, size: 20, pages: 1 }))
const params = { page: 1, size: 20, businessType: 'WAREHOUSE_TRANSFER', warehouseId: lot.warehouseId, status: 'IN_STOCK' }
const result = await listStorageRentLots(params)
expect(client.get).toHaveBeenCalledWith(base, { params })
expect(result.total).toBe(1)
expect(result.records[0]).toMatchObject(lot)
})
it('reads a lot with its actual charges and warehouse options from dedicated endpoints', async () => {
const charge = { id: '9223372036854775004', outboundNo: 'OUT1', quantity: 2, originalAmount: 23, currency: 'CNY', settlementAmount: 23 }
const currencyLot = { ...lot, generatedAmount: null, generatedCurrency: null, generatedAmounts: [{ currency: 'USD', amount: 12 }, { currency: 'CNY', amount: 23 }] }
client.get.mockResolvedValueOnce(envelope({ lot: currencyLot, charges: [charge] }))
.mockResolvedValueOnce(envelope([{ id: lot.warehouseId, warehouseName: '测试仓' }]))
const detail = await getStorageRentLot(lot.id)
expect(client.get).toHaveBeenNthCalledWith(1, `${base}/${lot.id}`)
expect(detail.lot.id).toBe(lot.id)
expect(detail.lot).toMatchObject(currencyLot)
expect(detail.charges).toEqual([charge])
expect(await listStorageRentWarehouses()).toEqual([{ id: lot.warehouseId, warehouseName: '测试仓' }])
expect(client.get).toHaveBeenNthCalledWith(2, `${base}/warehouse-options`)
})
})
@@ -0,0 +1,39 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { FeePage } from './overseasFees'
import type { OverseasFee } from './overseasFees'
export async function adjustStorageRentFee(id: string, body: { expectedVersion: number; amount: number; reason: string; idempotencyKey: string }) {
return unwrapApiResponse(await http.post<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(`/platform/v1/last-leg/storage-rent/fees/${encodeURIComponent(id)}/adjust`, body))
}
export interface StorageRentLot {
id: string; customerId: string; customerName: string; businessType: string; businessNo: string
warehouseId: string; warehouseName: string; batchNo: string; inboundAt: string; storageDays: number
originalQuantity: number; remainingQuantity: number; quantityUnit: string; billingQuantity: number
billingUnit: string; ruleCode: string; status: string; generatedAmount: number | null
generatedCurrency: string | null; generatedAmounts: Array<{ currency: string; amount: number }>
settledAmount?: number; chargeCount?: number; settledChargeCount?: number
}
export interface StorageRentCharge {
id: string; outboundNo: string; businessNo: string; quantity: number; storageDays: number
originalAmount: number; currency: string; settlementAmount: number; currencyRate: number; status: string
createdAt: string; outboundAt: string
}
export interface StorageRentDetail { lot: StorageRentLot; charges: StorageRentCharge[] }
const base = '/platform/v1/last-leg/storage-rent'
function normalizeLot(row: StorageRentLot): StorageRentLot {
return { ...row, id: String(row.id), customerId: String(row.customerId), warehouseId: String(row.warehouseId) }
}
export async function listStorageRentLots(params: Record<string, string | number | undefined>) {
const response = await http.get<ApiEnvelope<FeePage<StorageRentLot>>, ApiEnvelope<FeePage<StorageRentLot>>>(base, { params })
const data = unwrapApiResponse(response)
return { ...data, total: Number(data.total), records: data.records.map(normalizeLot) }
}
export async function getStorageRentLot(id: string) {
const data = unwrapApiResponse(await http.get<ApiEnvelope<StorageRentDetail>, ApiEnvelope<StorageRentDetail>>(`${base}/${encodeURIComponent(id)}`))
return { lot: normalizeLot(data.lot), charges: data.charges.map(row => ({ ...row, id: String(row.id) })) }
}
export async function listStorageRentWarehouses() {
const data = unwrapApiResponse(await http.get<ApiEnvelope<Array<{ id: string; warehouseName: string }>>, ApiEnvelope<Array<{ id: string; warehouseName: string }>>>(`${base}/warehouse-options`))
return data.map(row => ({ ...row, id: String(row.id) }))
}
@@ -0,0 +1,59 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BusinessDocumentFees from './BusinessDocumentFees.vue'
import { listBusinessFeeOptions } from '../api/overseasFees'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/overseasFees', () => ({ listBusinessFeeOptions: vi.fn() }))
vi.mock('./BusinessOrderFees.vue', () => ({ default: { name: 'BusinessOrderFees', props: ['business', 'relatedBusinesses', 'canCreate', 'readonly', 'direction'], template: '<div><slot name="toolbar-actions" /></div>' } }))
const business = { businessType: 'LASTLEG_ORDER', businessId: '9223372036854775001', businessNo: 'TB-01', customerId: 'C1', customerName: '客户', orderId: '9223372036854775001', warehouseId: null }
function page(props = {}) {
return mount(BusinessDocumentFees, { props: { ...business, ...props }, global: { stubs: {
LoadErrorState: { props: ['message'], template: '<div>{{ message }}</div>' }, ApiErrorMeta: true, ElSkeleton: true, ElAlert: true,
} } })
}
beforeEach(() => { vi.resetAllMocks(); can.mockReturnValue(true); vi.mocked(listBusinessFeeOptions).mockResolvedValue([business]) })
describe('单据费用归属', () => {
it('passes the payable direction to the review panel', async () => {
const wrapper = page({ direction: 'PAYABLE', canCreate: false }); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props()).toMatchObject({ direction: 'PAYABLE', canCreate: false })
})
it('requires exact customer, type, number and long identity', async () => {
vi.mocked(listBusinessFeeOptions).mockResolvedValue([{ ...business, businessNo: 'TB-010' }, { ...business, customerId: 'C2' }, business])
const wrapper = page(); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business')).toEqual(business)
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('canCreate')).toBe(true)
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('readonly')).toBe(false)
expect(listBusinessFeeOptions).toHaveBeenCalledWith({ businessType: 'LASTLEG_ORDER', customerId: 'C1', keyword: 'TB-01' })
})
it('does not permit a missing or ambiguous match to create charges', async () => {
vi.mocked(listBusinessFeeOptions).mockResolvedValue([{ ...business, businessId: 'another' }])
const wrapper = page(); await flushPromises()
expect(wrapper.findComponent({ name: 'BusinessOrderFees' }).exists()).toBe(false)
expect(wrapper.text()).toContain('未找到唯一关联单据')
})
it('resolves source order charges alongside tail charges', async () => {
const source = { ...business, businessType: 'RETURN_ORDER', businessId: 'return-id', businessNo: 'TH-01', orderId: null }
vi.mocked(listBusinessFeeOptions).mockResolvedValueOnce([business]).mockResolvedValueOnce([source])
const wrapper = page({ relatedDocuments: [source] }); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('relatedBusinesses')).toEqual([source])
})
it('can resolve a unique relabel id without inventing a customer', async () => {
const wrapper = page({ customerId: undefined }); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business').customerId).toBe('C1')
})
it('keeps the real tail order usable when its source number is only an external reference', async () => {
vi.mocked(listBusinessFeeOptions).mockResolvedValueOnce([business]).mockResolvedValueOnce([])
const wrapper = page({ relatedDocuments: [{ businessType: 'RELABEL_ORDER', businessNo: 'external-reference', customerId: 'C1' }] })
await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business')).toEqual(business)
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('relatedBusinesses')).toEqual([])
})
it('does not query fees without read permission', async () => {
can.mockReturnValue(false)
const wrapper = page(); await flushPromises()
expect(listBusinessFeeOptions).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'BusinessOrderFees' }).exists()).toBe(false)
})
})
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import BusinessOrderFees from './BusinessOrderFees.vue'
import { listBusinessFeeOptions, listBusinessOverseasFees, type BusinessFeeOption, type OverseasFeeDirection } from '../api/overseasFees'
import { feePermissions } from '../overseasFeePresentation'
interface DocumentIdentity { businessType: string; businessNo: string; customerId?: string; businessId?: string }
class DocumentIdentityError extends Error {}
const props = withDefaults(defineProps<DocumentIdentity & { relatedDocuments?: DocumentIdentity[]; canCreate?: boolean; readonly?: boolean; direction?: OverseasFeeDirection; hideWhenEmpty?: boolean; heading?: string }>(), { canCreate: true, readonly: false, direction: 'RECEIVABLE', hideWhenEmpty: false })
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const canRead = computed(() => access.can(feePermissions.list) || access.can('platform:finance:customer-arap:list'))
const business = ref<BusinessFeeOption | null>(null), related = ref<BusinessFeeOption[]>([])
const loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const panel = ref<InstanceType<typeof BusinessOrderFees>>()
let generation = 0
async function load() {
const request = ++generation
business.value = null; related.value = []; error.value = null; loading.value = false
if (!canRead.value) return
loading.value = true
try {
const identities: DocumentIdentity[] = [props, ...(props.relatedDocuments || [])]
const resolved = await Promise.all(identities.map(async (identity, index) => {
if ((!identity.customerId && !identity.businessId) || !identity.businessNo) throw new DocumentIdentityError('单据信息不完整,无法读取费用')
const options = await listBusinessFeeOptions({ businessType: identity.businessType, customerId: identity.customerId, keyword: identity.businessNo })
// 候选搜索是模糊查询,只有当前客户和完整单号一致时才能承载收费。
const matches = options.filter(option => (!identity.customerId || option.customerId === identity.customerId) && option.businessType === identity.businessType
&& option.businessNo === identity.businessNo && (!identity.businessId || option.businessId === identity.businessId))
// 尾程来源号可能只是外部参考号;没有内部业务实体时不能据此造出费用归属。
if (index > 0 && matches.length === 0) return null
if (matches.length !== 1) throw new DocumentIdentityError(`未找到唯一关联单据:${identity.businessNo}`)
return matches[0]!
}))
if (request === generation) { business.value = resolved[0]!; related.value = resolved.slice(1).filter((value): value is BusinessFeeOption => value !== null) }
} catch (failure) { if (request === generation) error.value = failure instanceof DocumentIdentityError ? { message: failure.message } : getApiErrorDetails(failure, '单据费用加载失败') }
finally { if (request === generation) loading.value = false }
}
watch(() => [props.businessType, props.businessNo, props.customerId, props.businessId, props.relatedDocuments, canRead.value], load, { immediate: true, deep: true })
async function refresh() { if (business.value) await panel.value?.refresh(); else await load() }
/** 新增前读取完整单据范围,重复项目提示不受当前表格分页影响。 */
async function getFees() {
if (!business.value) await load()
if (!business.value || error.value) throw new Error(error.value?.message || '单据费用尚未加载完成')
return (await listBusinessOverseasFees([business.value, ...related.value], { page: 1, size: Number.MAX_SAFE_INTEGER }, props.direction)).records
}
defineExpose({ refresh, getFees })
</script>
<template>
<section aria-label="单据费用">
<el-alert v-if="!canRead" title="暂无费用查看权限" type="info" :closable="false" />
<LoadErrorState v-else-if="error" title="单据费用加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-skeleton v-else-if="loading" :rows="3" animated />
<BusinessOrderFees v-else-if="business" ref="panel" :business="business" :related-businesses="related" :direction="direction" :can-create="canCreate" :readonly="readonly" :hide-when-empty="hideWhenEmpty" :heading="heading" @changed="emit('changed')">
<template v-if="$slots['toolbar-actions']" #toolbar-actions><slot name="toolbar-actions" /></template>
</BusinessOrderFees>
</section>
</template>
@@ -0,0 +1,340 @@
import ElementPlus, { ElDialog } from 'element-plus'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import OverseasFeeEditor from './OverseasFeeEditor.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import * as api from '../api/overseasFees'
import type { OverseasFee } from '../api/overseasFees'
import BusinessOrderFees from './BusinessOrderFees.vue'
import { adjustStorageRentFee } from '../api/storageRent'
vi.mock('../api/storageRent', () => ({ adjustStorageRentFee: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/overseasFees', () => ({
actOnOverseasFee: vi.fn(), getOverseasFee: vi.fn(), listOverseasFeeCustomers: vi.fn(), listOverseasFeeItems: vi.fn(),
listOverseasFeeRetries: vi.fn(),
listOverseasFees: vi.fn(), listBusinessOverseasFees: vi.fn(), listBusinessFeeOptions: vi.fn(), createOverseasFee: vi.fn(),
}))
const fee: OverseasFee = {
direction: 'RECEIVABLE',
id: '9223372036854775001', sourceFeeId: '9223372036854775001', sourceVersion: 3,
customerId: '9223372036854775002', customerName: '测试客户', sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_TRANSFER',
businessId: '9223372036854775003', businessNo: 'WT202610220001', orderId: null, warehouseId: '9223372036854775004',
feeItemCode: 'HANDLING', feeItemName: '操作费', businessDate: '2026-10-22', currency: 'CNY', amount: 70,
originalCurrency: 'USD', originalAmount: 10, chargeableQuantity: null, quantityUnit: null, unitPrice: null,
periodStart: null, periodEnd: null, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', publicationStatus: 'UNPUBLISHED',
collectionMode: 'WALLET', walletStatus: 'FROZEN', walletErrorMessage: null, reviewReason: null, reviewerId: null, reviewedAt: null,
createdAt: '2026-10-22T10:00:00', updatedAt: '', allowedActions: ['APPROVE', 'REJECT', 'CANCEL'],
}
const business = { businessId: fee.businessId!, businessType: fee.businessType, businessNo: fee.businessNo, customerId: fee.customerId, customerName: fee.customerName, warehouseId: fee.warehouseId, orderId: fee.orderId }
function deferred<T>() {
let resolve!: (value: T) => void, reject!: (reason: unknown) => void
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
async function mountPage() {
const wrapper = mount(BusinessOrderFees, { props: { business }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await flushPromises()
return wrapper
}
async function click(wrapper: VueWrapper, label: string, selector = 'button') {
const button = wrapper.findAll(selector).find(node => node.text().trim() === label)
expect(button, label).toBeDefined()
await button!.trigger('click'); await flushPromises()
}
function activeDialog(wrapper: VueWrapper) {
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('modelValue'))
expect(dialog).toBeDefined()
return dialog!.getComponent({ name: 'ElDialogContent' })
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.mocked(api.listOverseasFeeRetries).mockResolvedValue([])
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(api.listOverseasFeeCustomers).mockResolvedValue([{ customerId: fee.customerId, customerName: fee.customerName }])
vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'HANDLING', feeTypeName: '操作费', defaultPrice: 20 }])
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [fee], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(fee)
vi.mocked(api.actOnOverseasFee).mockResolvedValue(fee)
vi.mocked(api.createOverseasFee).mockResolvedValue(fee)
vi.mocked(api.listBusinessFeeOptions).mockResolvedValue([{ businessId: fee.businessId!, businessType: fee.businessType, businessNo: fee.businessNo,
customerId: fee.customerId, customerName: fee.customerName, warehouseId: fee.warehouseId, orderId: null }])
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('单据费用操作', () => {
it('uses the same fee dialog for procurement with balance deduction selected by default', async () => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
const procurement = { ...business, businessType: 'OVERSEAS_PROCUREMENT', businessNo: 'OP-1' }
const wrapper = mount(BusinessOrderFees, { props: { business: procurement }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await flushPromises(); await click(wrapper, '新增费用')
const dialog = activeDialog(wrapper)
const form = dialog.getComponent({ name: 'ElForm' }).props('model')
expect(form.settlementMode).toBe('WALLET')
Object.assign(form, { feeItemCode: 'HANDLING', amount: 12.34 })
await click(dialog, '保存')
expect(api.createOverseasFee).toHaveBeenCalledWith(expect.objectContaining({ businessType: 'OVERSEAS_PROCUREMENT', sourceType: 'OVERSEAS_PROCUREMENT_FEE', businessId: procurement.businessId, customerId: procurement.customerId, settlementMode: 'WALLET', amount: 12.34 }), expect.any(String))
})
it.each(['RECEIVABLE', 'PAYABLE'] as const)('hides an empty %s block and reveals it when fees are added', async direction => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
const wrapper = mount(BusinessOrderFees, { props: { business, direction, hideWhenEmpty: true, canCreate: false, heading: direction === 'RECEIVABLE' ? '应收明细' : '应付明细' }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await flushPromises()
expect(wrapper.isVisible()).toBe(false)
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, direction }], total: 1, page: 1, size: 20, pages: 1 })
await wrapper.vm.refresh(); await flushPromises()
expect(wrapper.isVisible()).toBe(true)
expect(wrapper.get('h3').text()).toBe(direction === 'RECEIVABLE' ? '应收明细' : '应付明细')
expect(wrapper.text()).toContain('操作费')
expect(wrapper.findAll('button').some(button => button.text().trim() === '新增费用')).toBe(false)
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
await wrapper.vm.refresh(); await flushPromises()
expect(wrapper.isVisible()).toBe(false)
})
it('queries the exact document and creates without a second order selector or catalog default amount', async () => {
const wrapper = await mountPage()
expect(api.listBusinessOverseasFees).toHaveBeenCalledWith([business], { page: 1, size: 20 }, 'RECEIVABLE')
await click(wrapper, '新增费用')
expect(api.listBusinessFeeOptions).not.toHaveBeenCalled()
const dialog = activeDialog(wrapper)
expect(dialog.text()).not.toContain('业务类型')
expect(dialog.text()).not.toContain('业务订单')
expect(dialog.findComponent({ name: 'ElDatePicker' }).exists()).toBe(false)
const selects = dialog.findAllComponents({ name: 'ElSelect' })
selects[1]!.vm.$emit('update:modelValue', 'CNY')
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.34)
selects[0]!.vm.$emit('update:modelValue', 'HANDLING')
selects[0]!.vm.$emit('change', 'HANDLING')
await flushPromises()
expect(dialog.getComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(12.34)
expect(dialog.text()).toContain('USD 10.00 · 待审核')
await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled()
dialog.findAllComponents({ name: 'ElInput' }).find(input => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '补收本次额外操作费用')
await flushPromises()
await click(dialog, '保存')
expect(api.createOverseasFee).toHaveBeenCalledWith(expect.objectContaining({ ...business, feeRemark: '补收本次额外操作费用', amount: 12.34, currency: 'CNY', businessDate: new Date().toLocaleDateString('sv-SE') }), expect.any(String))
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('checks refreshed eligibility and rejects a fee belonging to another order', async () => {
const wrapper = await mountPage()
vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, allowedActions: [] })
await click(wrapper, '审核', '.el-table__body-wrapper button')
expect(wrapper.findAllComponents(ElDialog).some(dialog => dialog.props('modelValue'))).toBe(false)
vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, businessId: 'different-order' })
await click(wrapper, '审核', '.el-table__body-wrapper button')
expect(wrapper.findAllComponents(ElDialog).some(dialog => dialog.props('modelValue'))).toBe(false)
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
it('keeps supplier payables separate and reviews them for settlement without customer billing hints', async () => {
const payable: OverseasFee = { ...fee, businessType: 'LASTLEG_ORDER', direction: 'PAYABLE', supplierId: 'supplier-1', supplierName: '测试供应商', settlementMode: 'ON_ACCOUNT', collectionMode: 'BILL', walletStatus: 'NOT_REQUIRED' }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [payable], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(payable)
const wrapper = mount(BusinessOrderFees, { props: { business: { ...business, businessType: 'LASTLEG_ORDER' }, direction: 'PAYABLE' }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await flushPromises()
expect(api.listBusinessOverseasFees).toHaveBeenLastCalledWith([expect.objectContaining({ businessType: 'LASTLEG_ORDER' })], { page: 1, size: 20 }, 'PAYABLE')
expect(wrapper.text()).toContain('测试供应商'); expect(wrapper.text()).toContain('推送结算')
expect(wrapper.findAll('button').map(button => button.text())).not.toContain('新增费用')
await click(wrapper, '审核', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).not.toContain('请核对原报价')
await click(dialog, '审核通过')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'APPROVE', expect.objectContaining({ decision: 'APPROVE' }))
})
it('cannot review the opposite direction returned by a refreshed fee', async () => {
const wrapper = await mountPage()
vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, direction: 'PAYABLE' })
await click(wrapper, '审核', '.el-table__body-wrapper button')
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('modelValue'))).toBe(false)
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
it('keeps read-only documents read-only and closes an editor when the order changes', async () => {
const wrapper = await mountPage()
await click(wrapper, '新增费用')
await wrapper.setProps({ business: { ...business, businessId: 'other' } })
await flushPromises()
expect(wrapper.getComponent(OverseasFeeEditor).props('modelValue')).toBe(false)
await wrapper.setProps({ readonly: true })
const visibleButtons = wrapper.findAll('button').filter(button => button.isVisible()).map(button => button.text())
expect(visibleButtons).not.toContain('新增费用')
expect(visibleButtons).not.toContain('审核')
expect(visibleButtons).not.toContain('驳回')
expect(visibleButtons).not.toContain('取消费用')
})
it('does not create against an order or customer changed after the editor was opened', async () => {
const wrapper = mount(OverseasFeeEditor, { props: { modelValue: false, fee: null, business, items: [{ feeCode: 'HANDLING', feeTypeName: '操作费', defaultPrice: 20 }] }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await wrapper.setProps({ modelValue: true }); await flushPromises()
const dialog = activeDialog(wrapper)
dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'HANDLING')
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10)
await wrapper.setProps({ business: { ...business, customerId: 'different-customer' } })
await flushPromises(); await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled()
})
it('blocks automatic storage rent from manual addition', 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)
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()
})
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' }
const relatedFee = { ...fee, ...related, allowedActions: ['ADJUST'] }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [relatedFee], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(relatedFee)
const wrapper = await mountPage()
await wrapper.setProps({ relatedBusinesses: [related, { ...business, customerId: 'other-customer' }] })
await flushPromises()
expect(api.listBusinessOverseasFees).toHaveBeenLastCalledWith([business, related], { page: 1, size: 20 }, 'RECEIVABLE')
await click(wrapper, '调整', '.el-table__body-wrapper button')
expect(wrapper.getComponent(OverseasFeeEditor).props('business')).toEqual(related)
const dialog = activeDialog(wrapper)
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 15)
await flushPromises(); await click(dialog, '保存')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'ADJUST', expect.objectContaining({
expectedVersion: fee.sourceVersion, fee: expect.objectContaining({ businessId: related.businessId, businessType: 'RETURN_ORDER', amount: 15 }),
}))
})
it('shows a voided freeze-failure fixture as closed instead of pending review', async () => {
const closed = { ...fee, sourceStatus: 'VOIDED', reviewStatus: 'PENDING', walletStatus: 'FREEZE_FAILED', walletErrorMessage: '可用余额和授信额度不足', allowedActions: [] }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [closed], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(closed)
const wrapper = await mountPage()
expect(wrapper.text()).toContain('已作废')
expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())).toEqual(['详情'])
await click(wrapper, '详情', '.el-table__body-wrapper button')
expect(activeDialog(wrapper).text()).toContain('冻结失败')
expect(activeDialog(wrapper).text()).toContain('此费用已作废,无需继续审核')
expect(activeDialog(wrapper).text()).not.toContain('待审核')
})
it('retries a failed debit only when the refreshed server action is allowed', async () => {
const failed = { ...fee, walletStatus: 'SETTLE_FAILED', allowedActions: ['RETRY_SETTLE'] }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(failed)
const wrapper = await mountPage()
await click(wrapper, '重试扣费', '.el-table__body-wrapper button')
await click(activeDialog(wrapper), '重试扣费')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'RETRY_SETTLE', { idempotencyKey: expect.any(String) })
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('requires a rejection reason and submits the review contract', async () => {
const wrapper = await mountPage()
vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, sourceVersion: 5 })
await click(wrapper, '审核', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
const reject = dialog.findAll('button').find(button => button.text().trim() === '驳回')
expect(reject?.attributes('disabled')).toBeDefined()
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
await dialog.get('textarea').setValue(' 金额不符 ')
await click(dialog, '驳回')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'REJECT', { decision: 'REJECT', reason: '金额不符', expectedVersion: 5, idempotencyKey: expect.any(String) })
})
it('keeps rejected fees read-only while allowing failed frozen funds to be released', async () => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, reviewStatus: 'REJECTED', allowedActions: ['SUBMIT', 'RETRY_RELEASE', 'CANCEL'] }], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, reviewStatus: 'REJECTED', allowedActions: ['SUBMIT', 'RETRY_RELEASE', 'CANCEL'] })
const wrapper = await mountPage()
const visibleButtons = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(visibleButtons).toContain('详情')
expect(visibleButtons).not.toContain('审核')
expect(visibleButtons).not.toContain('驳回')
expect(visibleButtons).not.toContain('提交审核')
expect(visibleButtons).toContain('重试释放')
await click(wrapper, '重试释放', '.el-table__body-wrapper button')
await click(activeDialog(wrapper), '重试释放')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'RETRY_RELEASE', expect.objectContaining({ idempotencyKey: expect.any(String) }))
await click(wrapper, '详情', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain('审核状态')
expect(dialog.findAll('button').some(button => button.text().trim() === '审核')).toBe(false)
})
it('voids with the freshly loaded version and a required reason', async () => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['VOID'] }], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, sourceVersion: 5, allowedActions: ['VOID'] })
const wrapper = await mountPage()
await click(wrapper, '作废', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
await click(dialog, '作废')
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
await dialog.get('textarea').setValue(' 重复费用 ')
await click(dialog, '作废')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'VOID', { expectedVersion: 5, reason: '重复费用', idempotencyKey: expect.any(String) })
})
it('locks in-flight approvals and preserves diagnostics and idempotency when retried', async () => {
const pending = deferred<OverseasFee>()
vi.mocked(api.actOnOverseasFee).mockReturnValueOnce(pending.promise)
const wrapper = await mountPage()
await click(wrapper, '审核', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
await click(dialog, '审核通过')
expect(wrapper.findAllComponents(ElDialog).find(component => component.props('modelValue'))!.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
pending.reject(new ApiResponseError('余额服务暂不可用', 'FUNDING_ERROR', 409, null, 'test-request-1'))
await flushPromises()
expect(wrapper.findAllComponents(AppDialog).some(component => component.props('modelValue'))).toBe(true)
expect(dialog.text()).toContain('余额服务暂不可用')
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('')
const firstKey = vi.mocked(api.actOnOverseasFee).mock.calls[0]![2].idempotencyKey
await click(dialog, '审核通过')
expect(vi.mocked(api.actOnOverseasFee).mock.calls[1]![2].idempotencyKey).toBe(firstKey)
})
})
describe('仓租费用审核与调价', () => {
const rent: OverseasFee = { ...fee, feeItemCode: 'FEE_TYPE_077', feeItemName: '仓储费', allowedActions: ['APPROVE', 'REJECT', 'ADJUST_RENT'],
storageRent: { outboundNo: 'OUT-1', outboundAt: '2026-10-22T10:00:00Z', calculatedAmount: 10, chargedAmount: null,
lines: [
{ lotId: '1', inboundAt: '2026-10-01T10:00:00Z', quantity: 2, days: 21, calculatedAmount: 5, sourceBusinessType: 'LASTLEG_ORDER', sourceBusinessNo: 'WC-SOURCE-1', batchNo: 'B-SOURCE-1' },
{ lotId: '2', inboundAt: '2026-10-01T10:00:00Z', quantity: 2, days: 21, calculatedAmount: 5, sourceBusinessType: 'RETURN_ORDER', sourceBusinessNo: 'TH-SOURCE-2', batchNo: 'B-SOURCE-2' },
] } }
async function setupRent() {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [rent], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(rent)
return mountPage()
}
it('审核显示实际出入库与原计算额,批准以前不请求扣款', async () => {
const wrapper = await setupRent()
await click(wrapper, '审核', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain('入库时间'); expect(dialog.text()).toContain('出库时间')
expect(dialog.text()).toContain('原计算金额'); expect(dialog.text()).toContain('实扣金额')
expect(dialog.text()).toContain('来源单号'); expect(dialog.text()).toContain('头程尾程订单')
expect(dialog.text()).toContain('WC-SOURCE-1'); expect(dialog.text()).toContain('TH-SOURCE-2')
expect(dialog.text()).toContain('退货单'); expect(dialog.text()).toContain('B-SOURCE-2')
expect(dialog.text()).toContain('2026-10-01'); expect(api.actOnOverseasFee).not.toHaveBeenCalled()
await click(dialog, '审核通过')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(rent.sourceFeeId, 'APPROVE', expect.objectContaining({ decision: 'APPROVE' }))
})
it('调价说明必填,失败保留金额说明且同参数重试复用请求编号', async () => {
const wrapper = await setupRent()
vi.mocked(adjustStorageRentFee).mockRejectedValueOnce(new Error('暂时不可用')).mockResolvedValue(rent)
await click(wrapper, '调整仓租', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.getComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(10)
expect(dialog.findAll('button').find(button => button.text() === '保存调整')!.attributes('disabled')).toBeDefined()
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12)
dialog.findAllComponents({ name: 'ElInput' }).find(input => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '人工核对调整')
await flushPromises(); await click(dialog, '保存调整')
expect(dialog.text()).toContain('仓租操作失败')
await click(dialog, '保存调整')
const calls = vi.mocked(adjustStorageRentFee).mock.calls
expect(calls).toHaveLength(2); expect(calls[0]).toEqual(calls[1])
expect(calls[0]).toEqual([rent.sourceFeeId, expect.objectContaining({ amount: 12, reason: '人工核对调整', expectedVersion: 3 })])
})
it('只读单据在仓租弹框已打开时同样禁止修改', async () => {
const wrapper = await setupRent(); await click(wrapper, '调整仓租', '.el-table__body-wrapper button')
await wrapper.setProps({ readonly: true }); await flushPromises()
expect(activeDialog(wrapper).findAll('button').some(button => button.text() === '保存调整')).toBe(false)
expect(adjustStorageRentFee).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,217 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import '../overseasFeeDetails.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import RowActions from '@/shared/components/RowActions.vue'
import OverseasFeeEditor, { type FeeItemOption } from './OverseasFeeEditor.vue'
import OverseasFeeRetryHistory from './OverseasFeeRetryHistory.vue'
import StorageRentFeeDetails from './StorageRentFeeDetails.vue'
import StorageRentFeeActions from './StorageRentFeeActions.vue'
import { useFeeStatusRefresh } from '../useFeeStatusRefresh'
import { actOnOverseasFee, getOverseasFee, listBusinessOverseasFees, listOverseasFeeItems, type BusinessFeeOption, type OverseasFee, type OverseasFeeDirection } from '../api/overseasFees'
import { actionLabels, feeActions, feeMoney, feePermissions, feeProcessingLabel, feeTime, feeReviewLabel, feeFundingErrorLabel } from '../overseasFeePresentation'
const props = withDefaults(defineProps<{ business: BusinessFeeOption; relatedBusinesses?: BusinessFeeOption[]; readonly?: boolean; canCreate?: boolean; direction?: OverseasFeeDirection; hideWhenEmpty?: boolean; heading?: string }>(), { readonly: false, canCreate: true, direction: 'RECEIVABLE', hideWhenEmpty: false })
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const canEdit = computed(() => !props.readonly && access.can(feePermissions.edit))
const loading = ref(false), error = ref<ApiErrorDetails | null>(null), itemsError = ref<ApiErrorDetails | null>(null), itemsLoading = ref(false)
const page = ref(1), pageSize = ref(20), total = ref(0), rows = ref<OverseasFee[]>([]), items = ref<FeeItemOption[]>([])
const detailLoading = ref(''), detail = ref<OverseasFee | null>(null), detailOpen = ref(false)
const editorOpen = ref(false), editFee = ref<OverseasFee | null>(null)
const actionOpen = ref(false), saving = ref(false), actionError = ref<ApiErrorDetails | null>(null)
const actionForm = reactive({ action: '', reason: '', requestKey: '', requestFingerprint: '' })
const actionFee = ref<OverseasFee | null>(null)
const rentFee = ref<OverseasFee | null>(null), rentAction = ref(''), rentOpen = ref(false)
const actionTitle = computed(() => actionForm.action === 'REVIEW' ? '审核' : actionLabels[actionForm.action] || '确认操作')
const actionConfirmText = computed(() => actionLabels[actionForm.action] || '确认操作')
const actionConfirmType = computed(() => ['REJECT', 'VOID'].includes(actionForm.action) ? 'danger' : 'primary')
const businesses = computed(() => [props.business, ...(props.relatedBusinesses || [])].filter(scope => scope.customerId === props.business.customerId))
const businessKey = computed(() => props.direction + '|' + businesses.value.map(scope => [scope.businessType, scope.businessId, scope.customerId].join(':')).join('|'))
const feeBusiness = (fee: OverseasFee) => businesses.value.find(scope => fee.businessId === scope.businessId && fee.businessType === scope.businessType && fee.customerId === scope.customerId)
const belongsToBusiness = (fee: OverseasFee) => fee.direction === props.direction && !!feeBusiness(fee)
const editorBusiness = computed(() => editFee.value ? feeBusiness(editFee.value) || props.business : props.business)
function rowActions(fee: OverseasFee) {
return feeActions(fee, permission => !props.readonly && access.can(permission), !!detailLoading.value || saving.value)
}
const collectionLabel = (fee: OverseasFee) => fee.direction === 'PAYABLE' ? '推送结算' : fee.collectionMode === 'BILL' ? '账单结算' : '余额/授信'
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' },
{ prop: 'feeItemName', label: '费用项目', minWidth: 150 },
...(props.direction === 'PAYABLE' ? [{ prop: 'supplierName', label: '供应商', minWidth: 180 }] : []),
{ key: 'amount', label: '金额', width: 150, align: 'right', formatter: (row: OverseasFee) => feeMoney(row.originalAmount, row.originalCurrency) },
{ key: 'settlementAmount', label: '结算金额(CNY)', width: 145, align: 'right', formatter: (row: OverseasFee) => feeMoney(row.amount) },
{ prop: 'collectionMode', label: '结算方式', width: 110, formatter: collectionLabel },
{ prop: 'reviewStatus', label: '审核状态', width: 100, slotName: 'reviewStatus' },
{ prop: 'reviewerName', label: '审核人', width: 120, formatter: (row: OverseasFee) => row.reviewerName || row.reviewerId || '—' },
{ prop: 'reviewedAt', label: '审核时间', width: 160, formatter: (row: OverseasFee) => feeTime(row.reviewedAt) },
{ key: 'chargeStatus', label: '费用状态', width: 120, formatter: feeProcessingLabel },
{ prop: 'businessDate', label: '费用日期', width: 110 },
])
let loadSequence = 0, detailSequence = 0, itemsSequence = 0
const statusRefresh = useFeeStatusRefresh(rows, () => load(true), computed(() => !error.value))
async function load(background = false) {
if (!background) statusRefresh.reset()
const current = ++loadSequence; if (!background) loading.value = true; error.value = null
try {
const data = await listBusinessOverseasFees(businesses.value, { page: page.value, size: pageSize.value }, props.direction)
if (current !== loadSequence) return
if (page.value > 1 && page.value > data.pages) { page.value = Math.max(1, data.pages); return }
rows.value = data.records.filter(belongsToBusiness); total.value = data.total
const latestDetail = rows.value.find(row => row.sourceFeeId === detail.value?.sourceFeeId)
if (latestDetail) detail.value = latestDetail
} catch (failure) { if (current === loadSequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(failure, '费用列表加载失败') } }
finally { if (current === loadSequence) loading.value = false }
}
async function loadItems() {
const current = ++itemsSequence; itemsLoading.value = true; itemsError.value = null
try {
const result = await listOverseasFeeItems(props.direction)
if (current !== itemsSequence) return false
items.value = result
return true
} catch (failure) { if (current === itemsSequence) itemsError.value = getApiErrorDetails(failure, '费用项目加载失败'); return false }
finally { if (current === itemsSequence) itemsLoading.value = false }
}
async function saved() { emit('changed'); await load() }
async function freshFee(row: OverseasFee) {
const current = ++detailSequence; detailLoading.value = row.sourceFeeId
try { const data = await getOverseasFee(row.sourceFeeId); return current === detailSequence ? data : null }
catch (failure) { ElMessage.error(getApiErrorDetails(failure, '费用详情加载失败').message); return null }
finally { if (current === detailSequence) detailLoading.value = '' }
}
async function create() { if (!canEdit.value || !props.canCreate || props.direction === 'PAYABLE') return; if (!await loadItems() || !canEdit.value || !props.canCreate) return; editFee.value = null; editorOpen.value = true }
async function handleAction(row: OverseasFee, action: string) {
if (detailLoading.value || saving.value) return
const current = await freshFee(row)
if (!current) return
if (!belongsToBusiness(current)) { ElMessage.warning('费用所属单据已变化,请重新查询'); await load(); return }
if (action === 'DETAIL') { detail.value = current; detailOpen.value = true; return }
if (!rowActions(current).some(item => item.key === action)) {
ElMessage.warning('费用状态已更新,请重新查询'); await load(); return
}
detailOpen.value = false
if (current.storageRent && ['REVIEW', 'ADJUST_RENT'].includes(action)) { rentFee.value = current; rentAction.value = action; rentOpen.value = true; return }
if (action === 'ADJUST') { if (!await loadItems() || !canEdit.value || !belongsToBusiness(current)) return; editFee.value = current; editorOpen.value = true; return }
actionFee.value = current; actionForm.action = action
actionForm.reason = ''; actionForm.requestKey = ''; actionForm.requestFingerprint = ''; actionError.value = null; actionOpen.value = true
}
async function confirmAction() {
const fee = actionFee.value, action = actionForm.action
if (!fee || saving.value || !belongsToBusiness(fee)) return
if (action === 'REVIEW' || !rowActions(fee).some(item => item.key === action)) return
if (action === 'VOID' && !actionForm.reason.trim()) { ElMessage.warning('请填写原因'); return }
saving.value = true; actionError.value = null
try {
const body = action === 'VOID' ? { expectedVersion: fee.sourceVersion, reason: actionForm.reason.trim() }
: ['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action) ? { expectedVersion: fee.sourceVersion, eventId: fee.deliveryEventId } : {}
const fingerprint = JSON.stringify(body)
if (actionForm.requestFingerprint !== fingerprint || !actionForm.requestKey) { actionForm.requestFingerprint = fingerprint; actionForm.requestKey = crypto.randomUUID() }
const result = await actOnOverseasFee(fee.sourceFeeId, action, { ...body, idempotencyKey: actionForm.requestKey })
if (detail.value?.sourceFeeId === result.sourceFeeId) detail.value = result
actionOpen.value = false; ElMessage.success('操作已提交'); emit('changed'); await load()
} catch (failure) { actionError.value = getApiErrorDetails(failure, '费用操作失败') }
finally { saving.value = false }
}
async function submitReview(decision: 'APPROVE' | 'REJECT') {
const fee = actionFee.value
if (!fee || saving.value || !belongsToBusiness(fee)) return
if (!rowActions(fee).some(item => item.key === 'REVIEW') || !fee.allowedActions.includes(decision)) return
if (decision === 'REJECT' && !actionForm.reason.trim()) { ElMessage.warning('请填写驳回原因'); return }
const body = { decision, reason: actionForm.reason.trim() || undefined, expectedVersion: fee.sourceVersion }
const fingerprint = JSON.stringify(body)
if (actionForm.requestFingerprint !== fingerprint || !actionForm.requestKey) {
actionForm.requestFingerprint = fingerprint; actionForm.requestKey = crypto.randomUUID()
}
saving.value = true; actionError.value = null
try {
const result = await actOnOverseasFee(fee.sourceFeeId, decision, { ...body, idempotencyKey: actionForm.requestKey })
actionFee.value = result
actionOpen.value = false
ElMessage.success(decision === 'APPROVE' ? '费用审核通过' : '费用已驳回')
emit('changed'); await load()
} catch (failure) { actionError.value = getApiErrorDetails(failure, '费用审核失败') }
finally { saving.value = false }
}
watch([page, pageSize], () => void load())
watch(businessKey, () => {
loadSequence++; detailSequence++; itemsSequence++; detailLoading.value = ''; itemsLoading.value = false
rows.value = []; total.value = 0; detail.value = null; actionFee.value = null; rentOpen.value = false; rentFee.value = null
editorOpen.value = false; detailOpen.value = false; actionOpen.value = false; itemsError.value = null
if (page.value === 1) void load(); else page.value = 1
}, { immediate: true })
watch(canEdit, allowed => { if (!allowed) editorOpen.value = false })
watch(() => actionFee.value && rowActions(actionFee.value).some(item => item.key === actionForm.action), allowed => { if (!allowed && !saving.value) actionOpen.value = false })
defineExpose({ refresh: load })
</script>
<template>
<section v-show="!hideWhenEmpty || !!error || total > 0" class="business-order-fees admin-density admin-workbench" :aria-label="heading">
<h3 v-if="heading" class="business-fee-heading">{{ heading }}</h3>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" appearance="integrated" :preference-key="`business-order-fees-${direction.toLowerCase()}`" row-key="sourceFeeId" empty-text="暂无费用明细">
<template #toolbar-actions>
<slot name="toolbar-actions">
<el-button v-if="canEdit && canCreate && direction === 'RECEIVABLE'" type="primary" :loading="itemsLoading" @click="create"><Plus :size="16" />新增费用</el-button>
</slot>
</template>
<template #notices>
<LoadErrorState v-if="error" title="费用列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="itemsError" title="费用项目加载失败" :message="itemsError.message" :loading="itemsLoading" @retry="loadItems"><template #details><ApiErrorMeta :details="itemsError" /></template></LoadErrorState>
</template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as OverseasFee)" @action="handleAction(row as OverseasFee, $event)" /></template>
<template #reviewStatus="{ row }"><el-tag :type="(row as OverseasFee).reviewStatus === 'APPROVED' ? 'success' : (row as OverseasFee).reviewStatus === 'REJECTED' ? 'danger' : 'info'">{{ feeReviewLabel(row as OverseasFee) }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条费用</template>
</DataTable>
<StorageRentFeeActions v-model="rentOpen" :fee="rentFee" :action="rentAction" :readonly="readonly" @saved="saved" />
<OverseasFeeEditor v-model="editorOpen" :fee="editFee" :items="items" :business="editorBusiness" @saved="saved" />
<AppDialog append-to-body v-model="detailOpen" title="费用详情" width="860px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" cancel-text="关闭" fullscreenable>
<template v-if="detail">
<el-descriptions :column="2" border class="density-descriptions overseas-fee-descriptions">
<el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item>
<el-descriptions-item v-if="detail.feeRemark" label="费用说明" :span="2">{{ detail.feeRemark }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ feeMoney(detail.originalAmount, detail.originalCurrency) }}</el-descriptions-item><el-descriptions-item label="结算方式">{{ collectionLabel(detail) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '—' }}</el-descriptions-item>
<el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算金额">{{ feeMoney(detail.amount, detail.currency) }}</el-descriptions-item><el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算汇率">{{ detail.exchangeRate || '—' }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item>
<el-descriptions-item label="审核状态">{{ feeReviewLabel(detail) }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ feeProcessingLabel(detail) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.reviewerName || detail.reviewerId" label="审核人">{{ detail.reviewerName || detail.reviewerId }}</el-descriptions-item><el-descriptions-item v-if="detail.reviewedAt" label="审核时间">{{ feeTime(detail.reviewedAt) }}</el-descriptions-item>
<el-descriptions-item label="费用日期">{{ detail.businessDate }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ feeTime(detail.createdAt) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.periodStart || detail.periodEnd" label="计费期间" :span="2">{{ detail.periodStart }} — {{ detail.periodEnd }}</el-descriptions-item>
<el-descriptions-item v-if="detail.reviewReason" label="审核意见" :span="2">{{ detail.reviewReason }}</el-descriptions-item>
<el-descriptions-item v-if="detail.walletErrorMessage" :label="feeFundingErrorLabel(detail)" :span="2">{{ detail.walletErrorMessage }}<span v-if="detail.sourceStatus === 'VOIDED'">(此费用已作废,无需继续审核)</span></el-descriptions-item>
</el-descriptions>
<StorageRentFeeDetails :fee="detail" />
<OverseasFeeRetryHistory v-if="detailOpen" :fee-id="detail.sourceFeeId" :refresh-key="[detail.deliveryStatus, detail.deliveryErrorMessage, detail.walletStatus].join('|')" />
</template>
</AppDialog>
<AppDialog append-to-body v-model="actionOpen" :title="actionTitle" width="520px" class="admin-density-dialog admin-workbench is-dialog" :confirm-text="actionConfirmText" :confirm-type="actionConfirmType" :confirm-loading="saving" @confirm="confirmAction">
<el-descriptions v-if="actionFee" :column="1" border class="density-descriptions"><el-descriptions-item label="业务单号">{{ actionFee.businessNo }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ actionFee.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ feeMoney(actionFee.originalAmount, actionFee.originalCurrency) }}</el-descriptions-item><el-descriptions-item v-if="actionFee.feeRemark" label="费用说明">{{ actionFee.feeRemark }}</el-descriptions-item></el-descriptions>
<el-form v-if="actionForm.action === 'REVIEW' || actionForm.action === 'VOID'" label-position="top" class="fee-action-reason density-form" :disabled="saving"><el-form-item :label="actionForm.action === 'REVIEW' ? '驳回原因(驳回时必填)' : '原因'" :required="actionForm.action === 'VOID'"><el-input v-model="actionForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="actionForm.action === 'REVIEW' ? '审核通过可不填,驳回时请填写原因' : ''" /></el-form-item></el-form>
<el-alert v-if="actionForm.action === 'REVIEW' && actionFee?.direction === 'RECEIVABLE' && actionFee.businessType === 'LASTLEG_ORDER' && actionFee.collectionMode === 'BILL'" title="请核对原报价,已含费用请勿重复收取。" type="warning" :closable="false" />
<el-alert v-if="actionError" :title="actionError.message" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert>
<template #footer="{ close, submitting }">
<div v-if="actionForm.action === 'REVIEW'" class="app-dialog__footer">
<el-button v-if="actionFee?.allowedActions.includes('REJECT')" type="danger" :disabled="submitting || !actionForm.reason.trim()" @click="submitReview('REJECT')">驳回</el-button>
<el-button v-if="actionFee?.allowedActions.includes('APPROVE')" type="primary" :loading="submitting" :disabled="submitting" @click="submitReview('APPROVE')">审核通过</el-button>
</div>
<div v-else class="app-dialog__footer">
<el-button :disabled="submitting" @click="close">取消</el-button>
<el-button :type="actionConfirmType" :loading="submitting" @click="confirmAction">{{ actionConfirmText }}</el-button>
</div>
</template>
</AppDialog>
</section>
</template>
<style scoped>
.fee-action-reason { margin-top: 16px; }
.business-fee-heading { margin: var(--admin-space-3) 0; font-size: var(--admin-font-section); }
</style>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { actOnOverseasFee, createOverseasFee, listBusinessFeeOptions, listBusinessOverseasFees, type BusinessFeeOption, type OverseasFee, type SaveOverseasFee } from '../api/overseasFees'
import { businessLabels, feePermissions, sameProjectFees, sameProjectFeeSummary } from '../overseasFeePresentation'
export interface FeeItemOption { feeCode: string; feeTypeName: string; defaultPrice: number | null }
const props = defineProps<{ fee: OverseasFee | null; items: FeeItemOption[]; business?: BusinessFeeOption }>()
const access = useAccessPreviewStore()
const open = defineModel<boolean>({ required: true })
const emit = defineEmits<{ saved: [] }>()
const saving = ref(false), optionsLoading = ref(false), options = ref<BusinessFeeOption[]>([]), formRef = ref<FormInstance>()
const error = ref<ApiErrorDetails | null>(null)
const today = () => new Date().toLocaleDateString('sv-SE')
const form = reactive({ businessType: 'WAREHOUSE_TRANSFER', businessId: '', feeItemCode: '', feeRemark: '', settlementMode: '' as '' | 'BILL' | 'WALLET', currency: 'USD' as 'CNY' | 'USD', amount: 0, businessDate: today() })
const existingFees = ref<OverseasFee[]>([]), existingLoading = ref(false), existingError = ref('')
const duplicates = computed(() => sameProjectFees(existingFees.value, form.feeItemCode, props.fee?.direction || 'RECEIVABLE', props.fee?.sourceFeeId))
const title = computed(() => props.fee ? '调整费用' : '新增费用')
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-fee-editor', label: title, isOpen: open, model: form, requiredPermission: computed(() => feePermissions.edit) })
const rules: FormRules<typeof form> = {
businessType: [{ required: true, message: '请选择业务类型', trigger: 'change' }],
businessId: [{ required: true, message: '请选择业务订单', trigger: 'change' }],
feeItemCode: [{ required: true, message: '请选择费用项目', trigger: 'change' }],
amount: [{ validator: (_rule, value: number, callback) => callback(Number.isFinite(value) && value > 0 ? undefined : new Error('金额须大于0')), trigger: 'blur' }],
}
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 hasExistingItem = computed(() => !!props.fee && manualItems.value.some(item => item.feeCode === props.fee?.feeItemCode))
async function loadOptions(keyword = '') {
if (props.business) return
const sequence = ++optionSequence
optionsLoading.value = true
try {
const result = await listBusinessFeeOptions({ businessType: form.businessType, keyword })
if (sequence === optionSequence) {
const current = selected.value
options.value = current && !result.some(row => row.businessId === current.businessId) ? [current, ...result] : result
}
} catch (failure) { if (sequence === optionSequence) error.value = getApiErrorDetails(failure, '业务订单加载失败') }
finally { if (sequence === optionSequence) optionsLoading.value = false }
}
function changeBusinessType() { form.businessId = ''; options.value = []; void loadOptions() }
const selected = computed(() => props.business || options.value.find(row => row.businessId === form.businessId))
const selectedItem = computed(() => manualItems.value.find(item => item.feeCode === form.feeItemCode))
let existingSequence = 0
async function loadExistingFees() {
const sequence = ++existingSequence
existingFees.value = []; existingError.value = ''; existingLoading.value = false
if (!open.value || !selected.value) return
existingLoading.value = true
try {
const result = await listBusinessOverseasFees(selected.value, { page: 1, size: Number.MAX_SAFE_INTEGER }, props.fee?.direction || 'RECEIVABLE')
if (sequence === existingSequence) existingFees.value = result.records
} catch (failure) { if (sequence === existingSequence) existingError.value = getApiErrorDetails(failure, '已有费用加载失败').message }
finally { if (sequence === existingSequence) existingLoading.value = false }
}
watch([open, selected], loadExistingFees)
watch(open, async value => {
if (!value) { optionSequence++; optionsLoading.value = false; return }
savedFingerprint = ''; requestKey = ''
openedBusinessKey = businessKey()
error.value = null
const fee = props.fee
Object.assign(form, { businessType: props.business?.businessType || fee?.businessType || 'WAREHOUSE_TRANSFER', businessId: props.business?.businessId || fee?.businessId || '', feeItemCode: fee?.feeItemCode || '', feeRemark: fee?.feeRemark || '', settlementMode: fee?.direction === 'RECEIVABLE' && (fee?.settlementMode === 'BILL' || fee?.settlementMode === 'WALLET') ? fee.settlementMode : props.business?.businessType === 'OVERSEAS_PROCUREMENT' ? 'WALLET' : '', currency: fee?.originalCurrency || 'USD', amount: Number(fee?.originalAmount || 0), businessDate: fee?.businessDate || today() })
options.value = fee ? [{ businessId: fee.businessId || '', businessType: fee.businessType, businessNo: fee.businessNo, customerId: fee.customerId, customerName: fee.customerName, orderId: fee.orderId, warehouseId: fee.warehouseId }] : []
captureBaseline()
formRef.value?.clearValidate()
if (!fee && !props.business) await loadOptions()
})
const sourceTypes: Record<string, string> = {
LASTLEG_ORDER: 'OVERSEAS_ORDER_FEE', WAREHOUSE_TRANSFER: 'OVERSEAS_WAREHOUSE_FEE', RETURN_ORDER: 'OVERSEAS_WAREHOUSE_FEE',
DISCARD_ORDER: 'OVERSEAS_WAREHOUSE_FEE', RELABEL_ORDER: 'OVERSEAS_RELABEL_FEE', US_TRUCK: 'OVERSEAS_TRUCK_FEE',
LOCAL_LOOSEBOARD: 'OVERSEAS_LOOSEBOARD_FEE', OVERSEAS_PROCUREMENT: 'OVERSEAS_PROCUREMENT_FEE',
OVERSEAS_INBOUND: 'OVERSEAS_INBOUND_FEE', DELIVERY_ORDER: 'OVERSEAS_DELIVERY_FEE',
}
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 }
if (duplicates.value.length && !form.feeRemark.trim()) { ElMessage.warning('此单已有相同费用项目,请填写费用说明后继续添加'); return }
const wrongFee = props.business && props.fee && (props.fee.businessId !== props.business.businessId || props.fee.businessType !== props.business.businessType || props.fee.customerId !== props.business.customerId)
if (openedBusinessKey !== businessKey() || wrongFee || (props.business && (form.businessId !== props.business.businessId || form.businessType !== props.business.businessType))) {
ElMessage.error('单据已变化,请关闭后重新添加费用'); return
}
if (!props.fee && !sourceTypes[order.businessType]) { ElMessage.error('请通过本业务的费用流程添加'); return }
const item = selectedItem.value
if (!item && !props.fee) { ElMessage.error('请重新选择费用项目'); return }
saving.value = true; error.value = null
try {
const payload: SaveOverseasFee = {
...order, sourceType: props.fee?.sourceType || sourceTypes[order.businessType]!,
feeItemCode: form.feeItemCode, feeItemName: item?.feeTypeName || props.fee!.feeItemName, direction: props.fee?.direction || 'RECEIVABLE',
businessDate: form.businessDate, currency: form.currency, amount: form.amount, feeRemark: form.feeRemark.trim() || undefined,
...(form.settlementMode ? { settlementMode: form.settlementMode } : {}),
...(props.fee?.direction === 'PAYABLE' ? {
supplierId: props.fee.supplierId, supplierName: props.fee.supplierName, supplierCategory: props.fee.supplierCategory,
settlementMode: props.fee.settlementMode, paidAt: props.fee.paidAt, paymentMethod: props.fee.paymentMethod,
paymentReferenceNo: props.fee.paymentReferenceNo, paymentVoucherFileIds: props.fee.paymentVoucherFileIds,
} : {}),
}
const fingerprint = JSON.stringify(payload)
if (fingerprint !== savedFingerprint || !requestKey) { savedFingerprint = fingerprint; requestKey = crypto.randomUUID() }
const idempotencyKey = requestKey
if (props.fee) await actOnOverseasFee(props.fee.sourceFeeId, 'ADJUST', { expectedVersion: props.fee.sourceVersion, fee: payload, idempotencyKey })
else await createOverseasFee(payload, idempotencyKey)
captureBaseline(); open.value = false; ElMessage.success(props.fee ? '费用已调整' : '费用已创建'); emit('saved')
} catch (failure) { error.value = getApiErrorDetails(failure, '费用保存失败') }
finally { saving.value = false }
}
</script>
<template>
<AppDialog append-to-body v-model="open" :title="title" width="680px" class="admin-density-dialog admin-workbench is-dialog" confirm-text="保存" :confirm-disabled="optionsLoading || !access.can(feePermissions.edit)" :confirm-loading="saving" fullscreenable @confirm="save">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="density-form" :disabled="saving">
<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="币种"><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>
<el-form-item label="费用说明" class="fee-remark" :required="duplicates.length > 0"><el-input v-model="form.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item>
</div>
<div v-if="duplicates.length" class="fee-existing" role="status"><span>此单已有相同费用,确认本次为新增作业后可继续添加。</span><p v-for="row in duplicates" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</p></div>
<div v-if="existingError" class="fee-existing-error">{{ existingError }} <el-button link type="primary" @click="loadExistingFees">重新加载</el-button></div>
</el-form>
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
</AppDialog>
</template>
<style scoped>
.fee-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
.fee-editor-grid :deep(.el-select), .fee-editor-grid :deep(.el-date-editor), .fee-editor-grid :deep(.el-input-number) { width: 100%; }
.fee-remark { grid-column: 1 / -1; }
.fee-existing { color: var(--cc-text-secondary, #606266); font-size: 13px; margin-bottom: 12px; }
.fee-existing p { margin: 4px 0; }
.fee-existing-error { color: var(--cc-danger, #f56c6c); }
</style>
@@ -0,0 +1,65 @@
import ElementPlus from 'element-plus'
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import { listOverseasFeeRetries } from '../api/overseasFees'
import OverseasFeeRetryHistory from './OverseasFeeRetryHistory.vue'
vi.mock('../api/overseasFees', () => ({ listOverseasFeeRetries: vi.fn() }))
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.mocked(listOverseasFeeRetries).mockReset().mockResolvedValue([])
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
})
afterEach(() => vi.unstubAllGlobals())
describe('费用重试记录', () => {
it('shows every retry with its actual operator, timestamp, result and failure reason', async () => {
vi.mocked(listOverseasFeeRetries).mockResolvedValue([
{ requestId: 'retry-2', requestedAt: '2026-10-23T11:22:33', operatorName: '李四 [2]', action: '重试推送', result: 'SUCCESS', errorMessage: null },
{ requestId: 'retry-1', requestedAt: '2026-10-23T10:20:30', operatorName: '张三 [1]', action: '重试扣费', result: 'FAILED', errorMessage: '可用额度不足' },
])
const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: '9007199254740993' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listOverseasFeeRetries).toHaveBeenCalledWith('9007199254740993')
const text = wrapper.text()
for (const value of ['李四 [2]', '张三 [1]', '2026-10-23 11:22:33', '2026-10-23 10:20:30', '重试成功', '重试后仍失败', '可用额度不足']) expect(text).toContain(value)
})
it('keeps request failures visible and retries without losing the fee identity', async () => {
vi.mocked(listOverseasFeeRetries).mockRejectedValueOnce(new ApiResponseError('查询暂不可用', 'UNAVAILABLE', 503))
const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: 'fee-1' }, global: { plugins: [ElementPlus] } })
await flushPromises(); expect(wrapper.text()).toContain('查询暂不可用')
await wrapper.get('button').trigger('click'); await flushPromises()
expect(listOverseasFeeRetries).toHaveBeenLastCalledWith('fee-1'); expect(wrapper.text()).toContain('暂无重试记录')
})
it('explains the failed settlement step in Chinese and keeps full technical details collapsed', async () => {
const raw = 'EventDeliveryFailure: Consumer rejected event: code=S0500, message=Internal server error, requestId=original-request, traceId=original-trace'
vi.mocked(listOverseasFeeRetries).mockResolvedValue([
{ requestId: 'r2', requestedAt: '', operatorName: '李四', action: '重试推送', result: 'SUCCESS', errorMessage: null },
{ requestId: 'r1', requestedAt: '', operatorName: '张三', action: '重试推送', result: 'FAILED', errorMessage: raw },
])
const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: 'fee-1' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('推送结算失败:财务系统处理异常,请联系管理员排查后重试。')
expect(wrapper.findAll('details')).toHaveLength(1)
expect(wrapper.get('details').attributes('open')).toBeUndefined()
expect(wrapper.get('summary').text()).toBe('查看技术详情')
expect(wrapper.get('pre').text()).toBe(raw)
})
it('ignores a late response from another fee', async () => {
let resolve!: (rows: Awaited<ReturnType<typeof listOverseasFeeRetries>>) => void
vi.mocked(listOverseasFeeRetries).mockReturnValueOnce(new Promise(yes => { resolve = yes }))
const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: 'old-fee' }, global: { plugins: [ElementPlus] } })
await wrapper.setProps({ feeId: 'new-fee' }); await flushPromises()
resolve([{ requestId: 'old', requestedAt: '', operatorName: '旧费用操作人', action: '重试', result: 'SUCCESS', errorMessage: null }]); await flushPromises()
expect(wrapper.text()).not.toContain('旧费用操作人')
})
it('refreshes the open history when the actual delivery result changes', async () => {
const record = { requestId: 'r1', requestedAt: '', operatorName: '张三', action: '重试推送', result: 'SUBMITTED', errorMessage: null } as const
vi.mocked(listOverseasFeeRetries).mockResolvedValueOnce([record]).mockResolvedValueOnce([{ ...record, result: 'SUCCESS' }])
const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: 'fee-1', refreshKey: 'PENDING' }, global: { plugins: [ElementPlus] } })
await flushPromises(); expect(wrapper.text()).toContain('已提交重试')
await wrapper.setProps({ refreshKey: 'PUBLISHED' }); await flushPromises()
expect(wrapper.text()).toContain('重试成功'); expect(wrapper.text()).not.toContain('已提交重试')
expect(listOverseasFeeRetries).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listOverseasFeeRetries, type OverseasFeeRetryRecord } from '../api/overseasFees'
import { feeTime, retryResultLabels } from '../overseasFeePresentation'
import { retryFailureReason } from '../overseasFeeRetryPresentation'
const props = defineProps<{ feeId: string; refreshKey?: string }>()
const rows = ref<OverseasFeeRetryRecord[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const columns: DataTableColumn[] = [
{ prop: 'requestedAt', label: '重试时间', width: 160, formatter: (row: OverseasFeeRetryRecord) => feeTime(row.requestedAt) },
{ prop: 'operatorName', label: '操作人', width: 170 },
{ prop: 'action', label: '重试操作', width: 100 },
{ prop: 'result', label: '操作结果', width: 110, formatter: (row: OverseasFeeRetryRecord) => retryResultLabels[row.result] || '—' },
{ key: 'failureReason', label: '失败原因', minWidth: 230, slotName: 'failureReason', singleLine: false, showOverflowTooltip: false },
]
let sequence = 0
async function load(background = false) {
const current = ++sequence; error.value = null
if (!background) { rows.value = []; loading.value = true }
try { const result = await listOverseasFeeRetries(props.feeId); if (current === sequence) rows.value = result }
catch (failure) { if (current === sequence) error.value = getApiErrorDetails(failure, '重试记录加载失败') }
finally { if (current === sequence) loading.value = false }
}
watch(() => props.feeId, () => load(), { immediate: true })
// 列表取得新的投递结果时同步刷新记录,不要求关闭再打开详情。
watch(() => props.refreshKey, () => load(true))
onBeforeUnmount(() => { sequence++ })
</script>
<template>
<section class="density-dialog-section" aria-label="重试记录">
<h3 class="density-dialog-section__title">重试记录</h3>
<LoadErrorState v-if="error" title="重试记录加载失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="requestId" column-settings-mode="hide" empty-text="暂无重试记录">
<template #failureReason="{ row }">
<div class="retry-failure-reason">{{ retryFailureReason(row as OverseasFeeRetryRecord) }}</div>
<details v-if="row.result === 'FAILED' && row.errorMessage" class="retry-failure-details">
<summary>查看技术详情</summary>
<pre>{{ row.errorMessage }}</pre>
</details>
</template>
</DataTable>
</section>
</template>
<style scoped>
.retry-failure-reason { white-space: normal; overflow-wrap: anywhere; }
.retry-failure-details { margin-top: 4px; }
.retry-failure-details summary { color: var(--el-color-primary); cursor: pointer; width: fit-content; }
.retry-failure-details pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; color: var(--el-text-color-secondary); }
</style>
@@ -4,6 +4,8 @@ import { computed } from 'vue'
import type { ProcurementCommand } from '../api/procurementCommands'
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
import { allowedDecisions, decisionLabels, remainingLabels, type ControlForm } from '../procurementControlForm'
import { regionText } from '../procurementDirection'
const t = (text: string) => regionText(text, props.detail.customerView.order.procurementDirection)
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[] }>()
const form = defineModel<ControlForm>({ required: true })
const proofs = computed(() => props.files.filter(file => file.category === 'EXCEPTION'))
@@ -14,7 +16,7 @@ const blockers = computed(() => props.detail.controls.notes.filter(note => note.
<template v-if="command === 'control-notes'">
<el-alert title="追加说明保留原购买、仓库、实收及交寄事实。需要阻断时,正式装箱、预估核对、核费及交寄须先处理异常。" type="info" :closable="false" />
<el-form-item label="关联事实类型" required><el-select v-model="form.targetType" placeholder="选择需要留痕的事实" @change="form.targetId = ''"><el-option value="PURCHASE" label="真实购买" /><el-option value="WAREHOUSE" label="实际收货仓" /><el-option value="RECEIPT" label="实收记录" /><el-option value="SHIPMENT" label="实际交寄记录" /></el-select></el-form-item>
<el-form-item v-if="form.targetType === 'RECEIPT'" label="关联实收" required><el-select v-model="form.targetId" placeholder="选择本单实收记录"><el-option v-for="row in detail.physical.receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${row.active ? '' : '(已更正)'}`" /></el-select></el-form-item>
<el-form-item v-if="form.targetType === 'RECEIPT'" label="关联实收" required><el-select v-model="form.targetId" placeholder="选择本单实收记录"><el-option v-for="row in detail.physical.receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · ${t(row.leg === 'OVERSEAS' ? '海外' : '国内')}${row.active ? '' : '(已更正)'}`" /></el-select></el-form-item>
<el-form-item v-if="form.targetType === 'SHIPMENT'" label="关联实际交寄" required><el-select v-model="form.targetId" placeholder="选择本单实际交寄记录"><el-option v-for="row in detail.transport.shipments" :key="row.id" :value="row.id" :label="`${row.shipmentNo} · ${row.leg === 'OVERSEAS' ? '国际段' : '寄客户段'}`" /></el-select></el-form-item>
<dl v-if="form.targetType === 'WAREHOUSE'" class="density-fields"><div><dt>当前实际仓</dt><dd>{{ detail.customerView.order.actualWarehouseName || '尚无实际仓' }}</dd></div></dl>
<el-form-item label="是否阻断后续正式作业" required><el-radio-group v-model="form.blocking"><el-radio :value="true">需要阻断</el-radio><el-radio :value="false">仅追加说明</el-radio></el-radio-group></el-form-item>
@@ -7,6 +7,8 @@ import { chargeDraft, feeRetryPermission, refundAvailable, targetPreview, replac
import { money, decimal, feePurposes } from '../procurementPresentation'
import { commandKey } from '../procurementPending'
import ProcurementChargeFields from './ProcurementChargeFields.vue'
import { regionText } from '../procurementDirection'
const t = (text: string) => regionText(text, props.detail.customerView.order.procurementDirection)
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[]; can: (permission: string) => boolean }>()
const form = defineModel<FinanceForm>({ required: true })
const candidates = computed(() => props.detail.fees.filter(fee => {
@@ -34,8 +36,8 @@ function addOther() { form.value.otherDetermination = 'ITEMIZED'; const row = ch
<div class="admin-density">
<template v-if="command === 'charges/confirm'">
<el-alert v-if="detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" title="本单已完成过核费,本次只引用已有费用版本;新增费用或重新核定请使用财务操作。" type="info" :closable="false" />
<el-alert title="一次确认实际国际运费、关税、国内运费和其他费用。正额确认后发起资金处理;确认成功不代表已扣款或可交寄。" type="info" :closable="false" />
<section v-for="(row, index) in form.fixed" :key="row.purpose" class="charge-section"><h3>{{ row.name }}</h3><ProcurementChargeFields v-model="form.fixed[index]" :detail="detail" :files="files" /></section>
<el-alert :title="t('一次确认实际国际运费、关税、国内运费和其他费用。正额确认后发起资金处理;确认成功不代表已扣款或可交寄。')" type="info" :closable="false" />
<section v-for="(row, index) in form.fixed" :key="row.purpose" class="charge-section"><h3>{{ row.purpose === 'DOMESTIC_FREIGHT' ? t('国内运费') : row.name }}</h3><ProcurementChargeFields v-model="form.fixed[index]" :detail="detail" :files="files" /></section>
<section class="charge-section"><h3>其他费用</h3><el-form-item label="其他费用适用情况" required><el-radio-group v-model="form.otherDetermination"><el-radio value="NOT_APPLICABLE" :disabled="detail.fees.some(row => row.active && row.purpose === 'OTHER')">不适用</el-radio><el-radio value="ITEMIZED">逐项核定</el-radio></el-radio-group></el-form-item>
<template v-if="form.otherDetermination === 'ITEMIZED'"><section v-for="(row, index) in form.others" :key="row.code" class="other-charge"><div class="charge-heading"><el-form-item label="费用名称" required><el-input v-model="row.name" :readonly="row.mode === 'EXISTING'" maxlength="128" placeholder="填写独立发生的费用名称" /></el-form-item><el-button v-if="row.mode === 'NEW'" class="density-action" data-tone="danger" @click="form.others.splice(index, 1)">移除新费用</el-button></div><ProcurementChargeFields v-model="form.others[index]" :detail="detail" :files="files" /></section><el-button v-if="!detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" class="density-action" @click="addOther">增加其他费用</el-button></template>
</section>
@@ -55,7 +57,7 @@ function addOther() { form.value.otherDetermination = 'ITEMIZED'; const row = ch
<el-alert v-if="command === 'fees/terminate'" title="仅终止明确失败的费用版本;仍有有效冻结时先释放,释放成功后才可重新核定,不能将未决结果当作失败。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/release-estimate'" title="释放有效预估冻结不产生退款或实际收费,后续交寄须重新满足预估核对条件。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/retry'" title="仅重试已明确失败的原资金请求,金额与费用版本不改变;处理中请刷新结果。" type="info" :closable="false" />
<el-form-item label="原费用" required><el-select v-model="form.feeId" placeholder="选择本单符合操作条件的费用"><el-option v-for="fee in candidates" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${feePurposes[fee.purpose] || fee.feeItemName} · ${money(fee.amount, fee.currency)}`" /></el-select></el-form-item>
<el-form-item label="原费用" required><el-select v-model="form.feeId" placeholder="选择本单符合操作条件的费用"><el-option v-for="fee in candidates" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${t(feePurposes[fee.purpose] || fee.feeItemName)} · ${money(fee.amount, fee.currency)}`" /></el-select></el-form-item>
<dl v-if="selected" class="density-fields"><div><dt>原币金额</dt><dd>{{ money(selected.originalAmount, selected.originalCurrency) }}</dd></div><div><dt>原汇率及月份</dt><dd>{{ selected.exchangeRate }} / {{ selected.exchangeRateMonth }}</dd></div></dl>
<el-form-item v-if="['fees/correct-target', 'fees/replace-failed'].includes(command)" :label="`正确原币总额(${selected?.originalCurrency || '先选择费用'})`" required><el-input v-model="form.targetOriginalAmount" placeholder="填写原费用链正确总额,最多两位小数" /></el-form-item>
<el-form-item v-if="command === 'fees/refund'" label="本次退款金额(CNY)" required><el-input v-model="form.refundAmount" placeholder="填写本次正额退款金额" /></el-form-item>
@@ -29,8 +29,8 @@ function render(detail = fixture(), permissions = ['review', 'buy', 'cancel', 'r
return mount(ProcurementNodeActions, { attachTo: document.body, props: { detail, customerId: '10001' }, global: { plugins: [pinia, ElementPlus] } })
}
async function open(wrapper: ReturnType<typeof render>) { await wrapper.find('.purchase-actions button').trigger('click'); await flushPromises(); return wrapper.findComponent(AppDialog) }
async function explain(wrapper: ReturnType<typeof render>, value = '已核对客户资料') { await wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '操作说明')!.find('textarea').setValue(value) }
async function confirm(wrapper: ReturnType<typeof render>) { wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() }
async function explain(wrapper: ReturnType<typeof render>, value = '已核对客户资料') { await wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => ['操作说明', '审核说明', '拒绝原因'].includes(row.props('label')))!.find('textarea').setValue(value) }
async function confirm(wrapper: ReturnType<typeof render>) { const dialog = wrapper.findComponent(AppDialog); const approve = dialog.findAll('button').find(button => button.text() === '通过'); if (approve) await approve.trigger('click'); else dialog.vm.$emit('confirm'); await flushPromises() }
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
beforeEach(() => {
@@ -40,6 +40,45 @@ beforeEach(() => {
vi.mocked(uploadProcurementFile).mockReset().mockResolvedValue({ id: '11', fileId: '101', category: 'PURCHASE', fileName: 'purchase.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' })
})
describe('procurement purchase node operations', () => {
it.each(['OVERSEAS_TO_DOMESTIC', 'DOMESTIC_TO_OVERSEAS'] as const)('locks review direction and requests the matching warehouse catalog: %s', async direction => {
const value = fixture(); value.customerView.order.procurementDirection = direction
const wrapper = render(value); const dialog = await open(wrapper)
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
const directionField = fields.find(item => item.props('label') === '采购方向')!
expect(directionField.findComponent({ name: 'ElSelect' }).exists()).toBe(false)
expect(directionField.text()).toContain(direction === 'DOMESTIC_TO_OVERSEAS' ? '国内采购,发往海外' : '海外采购,发往国内')
expect(listOverseasPurchaseWarehouseOptions).toHaveBeenLastCalledWith(direction)
expect(fields.some(item => item.props('label') === (direction === 'DOMESTIC_TO_OVERSEAS' ? '国内采购仓' : '海外采购仓'))).toBe(true)
expect(fields.some(item => item.props('label') === '审核决定')).toBe(false)
expect(dialog.findAll('button').map(button => button.text())).toEqual(expect.arrayContaining(['取消', '拒绝', '通过']))
expect(dialog.text()).not.toContain('确认提交')
})
it('allows approval without a remark after a blank rejection attempt once a warehouse is selected', async () => {
const wrapper = render(); const dialog = await open(wrapper)
await dialog.findAll('button').find(button => button.text() === '拒绝')!.trigger('click'); await flushPromises()
expect(submitProcurementCommand).not.toHaveBeenCalled()
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '海外采购仓')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001')
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'APPROVE', remark: '', purchaseWarehouseId: '20001' }))
})
it('requires a nonblank rejection reason and lets cancel close without changing the order', async () => {
const wrapper = render(); const dialog = await open(wrapper)
const reject = () => dialog.findAll('button').find(button => button.text() === '拒绝')!
await reject().trigger('click'); await flushPromises()
expect(submitProcurementCommand).not.toHaveBeenCalled()
expect(dialog.text()).toContain('请填写拒绝原因')
await explain(wrapper, ' '); await reject().trigger('click'); await flushPromises()
expect(submitProcurementCommand).not.toHaveBeenCalled()
await explain(wrapper, '供应商无法提供该商品'); await reject().trigger('click'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'REJECT', remark: '供应商无法提供该商品' }))
expect(vi.mocked(submitProcurementCommand).mock.calls[0]![3]).not.toHaveProperty('purchaseWarehouseId')
wrapper.unmount()
const next = render(); const nextDialog = await open(next)
await nextDialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
expect(nextDialog.props('modelValue')).toBe(false)
})
it('disables terminal physical changes and keeps the management evidence form open', async () => {
const value = fixture('MANAGE'); value.customerView.order.status = 'SHIPPED_TO_CUSTOMER'
value.purchaseFacts = { overseas_shipped_at: '2020-01-02', customer_shipped_at: '2020-01-03', actual_warehouse_id: '20' }
@@ -228,14 +267,14 @@ describe('procurement purchase node operations', () => {
it('requires an authoritative warehouse on approval and sends the current order version', async () => {
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper)
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('请选择有效采购仓')
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', { version: 3, idempotencyKey: expect.any(String), decision: 'APPROVE', remark: '已核对客户资料', purchaseWarehouseId: '20001' })
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '海外采购仓')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', { version: 3, idempotencyKey: expect.any(String), decision: 'APPROVE', procurementDirection: 'OVERSEAS_TO_DOMESTIC', remark: '已核对客户资料', purchaseWarehouseId: '20001' })
expect(wrapper.emitted('changed')).toHaveLength(1); expect(sessionStorage.getItem(storageKey)).toBeNull()
})
it('rejects without a warehouse and never substitutes an invented candidate', async () => {
vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('unavailable'))
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper, '购买对象不符合要求')
dialog.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECT'); await confirm(wrapper)
await dialog.findAll('button').find(button => button.text() === '拒绝')!.trigger('click'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'REJECT' }))
expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('purchaseWarehouseId')
})
@@ -23,6 +23,7 @@ import ProcurementTransportFields from './ProcurementTransportFields.vue'
import { transportCommands, transportForm, buildTransport } from '../procurementTransportForm'
import ProcurementPhysicalFields from './ProcurementPhysicalFields.vue'
import { physicalCommands, physicalForm, buildPhysical, managementOptions, managementReason, receiptCorrectionReason, dispositionReason } from '../procurementPhysicalForm'
import { directionLabel, regionText } from '../procurementDirection'
const props = defineProps<{ detail: PlatformProcurement; customerId: string }>()
const emit = defineEmits<{ changed: [] }>()
const auth = useAuthStore(); const access = useAccessPreviewStore()
@@ -39,6 +40,10 @@ const editingDraft = ref(false); const correctionDraft = ref<ProcurementCorrecti
const warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehouseLoading = ref(false); const warehouseError = ref('')
const extraFiles = ref<ProcurementFile[]>([])
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
const warehouseDirection = computed(() => props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC')
const purchaseWarehouseLabel = computed(() => warehouseDirection.value === 'DOMESTIC_TO_OVERSEAS' ? '国内采购仓' : '海外采购仓')
const rejecting = ref(false)
const t = (text: string) => regionText(text, warehouseDirection.value)
const control = ref(controlForm(props.detail))
const finance = ref(financeForm(props.detail))
const financialCommand = ref<ProcurementCommand>('fees/correct-target')
@@ -50,7 +55,7 @@ const draftKey = computed(() => key.value ? `${key.value}:correction-draft` : ''
const canDraft = computed(() => !!key.value && access.can('platform:overseas-business:purchase:finance-adjust') && props.detail.fees.some(fee => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)))
const commandPermissions: Record<ProcurementCommand, string> = { review: 'review', purchase: 'buy', 'purchase/confirm': 'buy', 'budget-authorizations': 'review', 'planned-warehouse': 'review', cancel: 'cancel', 'overseas-receipts': 'receive-overseas', 'domestic-receipts': 'receive-domestic', 'overseas-receipts/complete': 'receive-overseas', 'domestic-receipts/complete': 'receive-domestic', packing: 'pack', 'packing/confirm': 'pack', 'packing/withdraw': 'manage', dispositions: 'manage', 'receipts/correct': 'manage', 'dispositions/revoke': 'manage', labels: 'label', 'international-estimate': 'estimate', 'labels/apply': 'apply-label', 'overseas-shipment': 'ship-overseas', 'customer-shipment': 'ship-customer', 'domestic-repack': 'receive-domestic', 'charges/confirm': 'charge', 'fees/retry': 'charge', 'fees/terminate': 'finance-adjust', 'fees/release-estimate': 'finance-adjust', 'fees/correct-target': 'finance-adjust', 'fees/additional': 'finance-adjust', 'fees/replace-failed': 'finance-adjust', 'fees/refund': 'finance-adjust', 'control-notes': 'manage', 'control-notes/resolve': 'manage', 'actual-warehouse/correct': 'manage', 'exception-close': 'manage' }
const command = computed(() => editingDraft.value ? 'fees/correct-target' : pending.value?.command ?? (active.value?.key === 'MANAGE' ? manageCommand.value : active.value?.key === 'FINANCE_ADJUST' ? financialCommand.value : active.value ? routes[active.value.key] : undefined))
const manageOptions = computed(() => managementOptions.map(option => ({ ...option, reason: managementReason(option.value, props.detail) })))
const manageOptions = computed(() => managementOptions.map(option => ({ ...option, label: t(option.label), reason: managementReason(option.value, props.detail) ? t(managementReason(option.value, props.detail)!) : null })))
const managementBlock = computed(() => {
if (active.value?.key !== 'MANAGE' || !command.value || pending.value) return null
const reason = managementReason(command.value, props.detail)
@@ -66,7 +71,7 @@ const isFinance = computed(() => !!command.value && financeCommands.has(command.
const isTransport = computed(() => !!command.value && transportCommands.has(command.value))
const isPhysical = computed(() => !!command.value && physicalCommands.has(command.value))
const needsEvidence = computed(() => ['dispositions', 'receipts/correct', 'dispositions/revoke', 'domestic-repack', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(command.value || ''))
const needsWarehouse = computed(() => command.value === 'actual-warehouse/correct' || command.value === 'overseas-receipts' || (command.value === 'receipts/correct' && physical.value.leg === 'OVERSEAS') || command.value === 'planned-warehouse' || (command.value === 'review' && form.decision === 'APPROVE'))
const needsWarehouse = computed(() => command.value === 'actual-warehouse/correct' || command.value === 'overseas-receipts' || (command.value === 'receipts/correct' && physical.value.leg === 'OVERSEAS') || command.value === 'planned-warehouse' || command.value === 'review')
const needsAmount = computed(() => ['purchase', 'budget-authorizations'].includes(command.value || ''))
const fileCategory = computed(() => isControl.value ? 'EXCEPTION' : isFinance.value ? 'VOUCHER' : command.value === 'labels' ? 'LABEL' : command.value === 'domestic-repack' ? 'RECEIPT' : needsEvidence.value ? 'EXCEPTION' : command.value === 'purchase' ? 'PURCHASE' : 'BUDGET')
const allFiles = computed(() => [...props.detail.files, ...extraFiles.value].filter((file, index, all) => all.findIndex(other => other.fileId === file.fileId) === index))
@@ -78,13 +83,16 @@ watch(key, () => {
try { correctionDraft.value = readCorrectionDraft(sessionStorage.getItem(draftKey.value)) }
catch { draftError.value = '本浏览器的费用更正草稿无法读取,请核对后重新填写。' }
}, { immediate: true })
let warehouseGeneration = 0
async function loadWarehouses() {
if (warehouseLoading.value) return
warehouseLoading.value = true; warehouseError.value = ''
try { warehouses.value = await listOverseasPurchaseWarehouseOptions() }
catch { warehouseError.value = '采购仓候选加载失败,请重试'; warehouses.value = [] }
finally { warehouseLoading.value = false }
const current = ++warehouseGeneration
const direction = warehouseDirection.value
warehouseLoading.value = true; warehouseError.value = ''; warehouses.value = []
try { const result = await listOverseasPurchaseWarehouseOptions(direction); if (current === warehouseGeneration) warehouses.value = result }
catch { if (current === warehouseGeneration) { warehouseError.value = '采购仓候选加载失败,请重试'; warehouses.value = [] } }
finally { if (current === warehouseGeneration) warehouseLoading.value = false }
}
watch(warehouseDirection, () => { form.warehouseId = ''; warehouses.value = []; if (opened.value && needsWarehouse.value) void loadWarehouses() }, { flush: 'sync' })
function open(action: ProcurementAction) {
if (!action.allowed || pending.value || storageError.value || saving.value) return
editingDraft.value = false; active.value = action; error.value = null; manageCommand.value = manageOptions.value.find(option => !option.reason)?.value || 'control-notes'; physical.value = physicalForm(props.detail)
@@ -93,7 +101,7 @@ function open(action: ProcurementAction) {
transport.value = transportForm(props.detail, ['SHIP_CUSTOMER', 'REPACK'].includes(action.key) ? 'DOMESTIC' : action.key === 'LABELS' ? undefined : 'OVERSEAS')
if (action.key.includes('DOMESTIC')) physical.value.leg = 'DOMESTIC'
Object.assign(form, { decision: 'APPROVE', warehouseId: props.detail.purchaseFacts.actual_warehouse_id || '', reason: '', amount: '', currency: props.detail.customerView.budget.currency === 'USD' ? 'USD' : 'CNY', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
opened.value = true; captureBaseline()
rejecting.value = false; opened.value = true; captureBaseline()
if (needsWarehouse.value) void loadWarehouses()
}
function openCorrectionDraft() {
@@ -125,9 +133,9 @@ watch(financialCommand, () => { if (editingDraft.value) return; finance.value.fe
watch(needsWarehouse, value => { if (value && opened.value) void loadWarehouses() })
function build(): ProcurementCommandBody {
const order = props.detail.customerView.order
if (!form.reason.trim()) throw new Error('请填写操作说明')
if (!form.reason.trim() && (command.value !== 'review' || form.decision === 'REJECT')) throw new Error(command.value === 'review' ? '请填写拒绝原因' : '请填写操作说明')
const base = { version: order.version, idempotencyKey: commandKey(), reason: form.reason.trim() }
if (needsWarehouse.value && !warehouses.value.some(row => row.warehouseId === form.warehouseId)) throw new Error('请选择有效采购仓')
if (needsWarehouse.value && !(command.value === 'review' && form.decision === 'REJECT') && !warehouses.value.some(row => row.warehouseId === form.warehouseId)) throw new Error('请选择有效采购仓')
if (isControl.value && command.value) return buildControl(command.value, control.value, props.detail, allFiles.value, base, form.warehouseId, form.voucherFileId)
if (isFinance.value && command.value) return buildFinance(command.value, finance.value, props.detail, allFiles.value, base, form.voucherFileId)
if (isTransport.value && command.value) return buildTransport(command.value, transport.value, props.detail, allFiles.value, base, form.voucherFileId)
@@ -135,7 +143,7 @@ function build(): ProcurementCommandBody {
if (needsEvidence.value && !files.value.some(file => file.fileId === form.voucherFileId)) throw new Error('请选择本单异常处置凭证')
return buildPhysical(command.value, physical.value, props.detail, base, form.warehouseId, form.voucherFileId)
}
if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) }
if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, procurementDirection: warehouseDirection.value, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) }
if (command.value === 'planned-warehouse') return { ...base, warehouseId: form.warehouseId }
if (needsAmount.value) {
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(form.amount.trim())) throw new Error('请明确填写非负金额,最多两位小数;免费样品请填 0')
@@ -147,6 +155,12 @@ function build(): ProcurementCommandBody {
}
return base
}
async function review(decision: 'APPROVE' | 'REJECT') {
if (saving.value || uploading.value) return
rejecting.value = decision === 'REJECT'
form.decision = decision
await submit()
}
async function submit() {
if (editingDraft.value) { saveCorrectionDraft(); return }
if (saving.value || uploading.value || storageError.value || !key.value) return
@@ -202,9 +216,9 @@ async function upload(event: Event) {
</details>
<el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
<ApiErrorMeta v-if="error && !opened" :details="error" />
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : '确认提交'" @confirm="submit">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : '确认提交'" @confirm="command !== 'review' && submit()">
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
<el-alert v-if="managementBlock" :title="managementBlock" type="warning" :closable="false" />
<el-alert v-if="managementBlock" :title="t(managementBlock)" type="warning" :closable="false" />
<el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)">
<el-form-item v-if="active?.key === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option v-for="option in manageOptions" :key="option.value" :value="option.value" :disabled="!!option.reason" :label="option.reason ? `${option.label}(${option.reason})` : option.label" /></el-select></el-form-item>
<el-form-item v-if="active?.key === 'FINANCE_ADJUST'" label="财务处理方式"><el-select v-model="financialCommand" placeholder="选择财务处理方式"><el-option value="fees/additional" label="登记独立新发生费用" /><el-option value="fees/correct-target" label="更正原费用链目标总额" /><el-option value="fees/refund" label="关联原扣款退款" /><el-option value="fees/terminate" label="终止明确失败的费用版本" /><el-option value="fees/replace-failed" label="重新核定已终止的未扣款费用" /><el-option value="fees/release-estimate" label="释放有效预估冻结" /></el-select></el-form-item>
@@ -213,17 +227,17 @@ async function upload(event: Event) {
<ProcurementFinanceFields v-else-if="isFinance && command" v-model="finance" :command="command" :detail="detail" :files="allFiles" :can="access.can" />
<ProcurementTransportFields v-if="isTransport && command" v-model="transport" :command="command" :detail="detail" :files="allFiles" />
<ProcurementPhysicalFields v-if="isPhysical && command" v-model="physical" :command="command" :detail="detail" />
<el-form-item v-if="command === '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="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? '实际海外收货仓' : '采购仓'" required><el-select v-model="form.warehouseId" :disabled="isPhysical && !!detail.purchaseFacts.actual_warehouse_id" filterable :loading="warehouseLoading" placeholder="选择有效海外收货仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :value="item.warehouseId" :label="`${item.warehouseName} · ${item.warehouseCode}`" /></el-select><span v-if="warehouseError">{{ warehouseError }}<el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
<el-form-item v-if="command === 'review'" label="采购方向"><span>{{ directionLabel(warehouseDirection) }}</span></el-form-item>
<el-form-item v-if="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? t('实际海外收货仓') : purchaseWarehouseLabel" required><el-select v-model="form.warehouseId" :disabled="isPhysical && !!detail.purchaseFacts.actual_warehouse_id" filterable :loading="warehouseLoading" :placeholder="t('选择有效海外收货仓')"><el-option v-for="item in warehouses" :key="item.warehouseId" :value="item.warehouseId" :label="`${item.warehouseName} · ${item.warehouseCode}`" /></el-select><span v-if="warehouseError">{{ warehouseError }}<el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
<div v-if="needsAmount" class="purchase-form-grid"><el-form-item :label="command === 'purchase' ? '供应商实付总额' : '客户授权预算总额'" required><el-input v-model="form.amount" placeholder="明确填写金额,包括零元" /></el-form-item><el-form-item label="原币" required><el-select v-model="form.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item></div>
<div v-if="command === 'purchase'" class="purchase-form-grid"><el-form-item label="真实购买时间" required><el-date-picker v-model="form.purchasedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到海外仓时间" required><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></div>
<div v-if="command === 'purchase'" class="purchase-form-grid"><el-form-item label="真实购买时间" required><el-date-picker v-model="form.purchasedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item :label="t('预计到海外仓时间')" required><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></div>
<el-form-item v-if="command === 'exception-close'" label="上传异常处置或客户确认凭证"><input aria-label="上传异常关闭凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="['charges/confirm', 'fees/additional'].includes(command || '')" label="上传费用凭证"><input aria-label="上传费用凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="command === 'labels'" label="上传本单面单"><input aria-label="上传快递面单" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item label="操作说明" required><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : '确认提交' }}</el-button></div></template>
<el-form-item :label="command === 'review' ? (rejecting ? '拒绝原因' : '审核说明') : '操作说明'" :required="command !== 'review' || rejecting"><el-input v-model="form.reason" :placeholder="command === 'review' ? '拒绝时必须填写具体原因' : '请填写操作说明'" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</el-form><el-alert v-if="error" :title="t(error.message)" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
<template #footer><div class="density-actions density-actions--footer"><template v-if="command === 'review'"><el-button :disabled="saving" @click="opened = false">取消</el-button><el-button type="danger" :loading="saving && form.decision === 'REJECT'" :disabled="saving || !!storageError || !key" @click="review('REJECT')">拒绝</el-button><el-button type="primary" :loading="saving && form.decision === 'APPROVE'" :disabled="saving || warehouseLoading || !!storageError || !key" @click="review('APPROVE')">通过</el-button></template><template v-else><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : '确认提交' }}</el-button></template></div></template>
</AppDialog>
</section>
</template>
@@ -4,6 +4,8 @@ import { computed } from 'vue'
import type { ProcurementCommand } from '../api/procurementCommands'
import type { PlatformProcurement } from '../procurementTypes'
import { newCarton, shippedCartons, receiptCorrectionReason, dispositionReason, type PhysicalForm } from '../procurementPhysicalForm'
import { regionText } from '../procurementDirection'
const t = (text: string) => regionText(text, props.detail.customerView.order.procurementDirection)
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement }>()
const form = defineModel<PhysicalForm>({ required: true })
const receipts = computed(() => props.detail.physical.receipts.filter(row => row.active && (props.command === 'receipts/correct' || (row.leg === form.value.leg && row.sourceCartonId === (form.value.leg === 'DOMESTIC' ? form.value.sourceCartonId : null)))))
@@ -20,20 +22,20 @@ const differenceNames = { SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货'
<template>
<div class="admin-density">
<el-alert v-if="command.endsWith('/complete')" title="仅在全部到齐或差异已有明确处置后确认收货完成;登记实收不会自动完成收货。" type="info" :closable="false" />
<el-alert v-if="command === 'packing/confirm'" :title="`确认第 ${detail.physical.packingVersion || '—'} 版清单。须完成海外收货、样品货款及费用来源关联。`" type="info" :closable="false" />
<el-alert v-if="command === 'packing/confirm'" :title="`确认第 ${detail.physical.packingVersion || '—'} 版清单。须完成起运仓收货、样品货款及费用来源关联。`" type="info" :closable="false" />
<el-alert v-if="command === 'packing/withdraw'" title="撤回未交寄清单后,需要重新确认装箱并核对后续面单和费用。" type="warning" :closable="false" />
<el-form-item v-if="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS" :disabled="!!dispositionReason(detail, 'OVERSEAS')">海外收货<span v-if="dispositionReason(detail, 'OVERSEAS')">({{ dispositionReason(detail, 'OVERSEAS') }})</span></el-radio><el-radio value="DOMESTIC" :disabled="!!dispositionReason(detail, 'DOMESTIC')">国内收货<span v-if="dispositionReason(detail, 'DOMESTIC')">({{ dispositionReason(detail, 'DOMESTIC') }})</span></el-radio></el-radio-group></el-form-item>
<el-form-item v-if="command === 'receipts/correct'" label="待更正实收" required><el-select v-model="form.receiptId" placeholder="选择有效原实收记录" @change="selectReceipt"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select><span>保留原记录,更正不能改变运输段或来源箱;已交寄后只能追加更正说明。</span></el-form-item>
<el-form-item v-if="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS" :disabled="!!dispositionReason(detail, 'OVERSEAS')">{{ t('海外收货') }}<span v-if="dispositionReason(detail, 'OVERSEAS')">({{ dispositionReason(detail, 'OVERSEAS') }})</span></el-radio><el-radio value="DOMESTIC" :disabled="!!dispositionReason(detail, 'DOMESTIC')">{{ t('国内收货') }}<span v-if="dispositionReason(detail, 'DOMESTIC')">({{ dispositionReason(detail, 'DOMESTIC') }})</span></el-radio></el-radio-group></el-form-item>
<el-form-item v-if="command === 'receipts/correct'" label="待更正实收" required><el-select v-model="form.receiptId" placeholder="选择有效原实收记录" @change="selectReceipt"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.receiptNo} · ${t(row.leg === 'OVERSEAS' ? '海外' : '国内')}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select><span>保留原记录,更正不能改变运输段或来源箱;已交寄后只能追加更正说明。</span></el-form-item>
<el-form-item v-if="(showReceipt || command === 'dispositions') && form.leg === 'DOMESTIC'" label="国际交寄原箱" required><el-select v-model="form.sourceCartonId" :disabled="command === 'receipts/correct'" placeholder="选择本单已国际交寄的箱" @change="form.receiptId = ''"><el-option v-for="box in domesticBoxes" :key="box.id" :value="box.id" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<template v-if="showReceipt"><div class="physical-grid"><el-form-item label="合格件数" required><el-input-number v-model="form.goodQuantity" :min="0" :precision="0" controls-position="right" /></el-form-item><el-form-item label="破损件数" required><el-input-number v-model="form.damagedQuantity" :min="0" :precision="0" controls-position="right" /></el-form-item><el-form-item label="多货件数" required><el-input-number v-model="form.extraQuantity" :min="0" :precision="0" controls-position="right" /></el-form-item></div><el-form-item label="实际收货时间" required><el-date-picker v-model="form.receivedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></template>
<template v-if="command === 'dispositions'"><el-form-item label="差异及处置" required><el-select v-model="form.kind" placeholder="选择差异处理方式" @change="form.receiptId = ''; form.quantity = undefined"><el-option value="SHORTAGE" label="短少 · 客户接受短少" /><el-option value="DAMAGED" label="破损 · 处理破损货物" /><el-option value="EXTRA" label="多货 · 退回多到货物" /></el-select></el-form-item><el-form-item v-if="form.kind !== 'SHORTAGE'" label="对应实收" required><el-select v-model="form.receiptId" placeholder="选择差异来源实收"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · 破损 ${row.damagedQuantity} / 多货 ${row.extraQuantity}`" /></el-select></el-form-item><el-form-item label="本次处置件数" required><el-input-number v-model="form.quantity" :min="1" :precision="0" controls-position="right" placeholder="填写已完成处置的件数" /></el-form-item></template>
<el-form-item v-if="command === 'dispositions/revoke'" label="撤销差异处置" required><el-select v-model="form.dispositionId" placeholder="选择有效处置记录"><el-option v-for="row in detail.physical.dispositions.filter(item => item.active)" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.leg === 'OVERSEAS' ? '海外' : '国内'} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select></el-form-item>
<el-form-item v-if="command === 'dispositions/revoke'" label="撤销差异处置" required><el-select v-model="form.dispositionId" placeholder="选择有效处置记录"><el-option v-for="row in detail.physical.dispositions.filter(item => item.active)" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${t(row.leg === 'OVERSEAS' ? '海外' : '国内')} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select></el-form-item>
<template v-if="command === 'packing'">
<el-alert title="每箱填写箱规、毛重及来源实收件数。草稿允许暂未分配完,确认时必须覆盖全部合格实收。" type="info" :closable="false" />
<section v-for="(box, index) in form.cartons" :key="index" class="packing-box">
<div class="packing-heading"><strong>第 {{ index + 1 }} 箱</strong><el-button class="density-action" data-tone="danger" @click="form.cartons.splice(index, 1)">移除本箱</el-button></div>
<div class="packing-grid"><el-form-item label="箱号" required><el-input v-model="box.cartonNo" maxlength="64" placeholder="填写本单唯一箱号" /></el-form-item><el-form-item label="长(cm)" required><el-input v-model="box.lengthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="宽(cm)" required><el-input v-model="box.widthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="高(cm)" required><el-input v-model="box.heightCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="毛重(kg)" required><el-input v-model="box.grossWeightKg" placeholder="最多三位小数" /></el-form-item></div>
<div v-for="(source, sourceIndex) in box.sources" :key="sourceIndex" class="packing-source"><el-form-item label="海外实收来源" required><el-select v-model="source.receiptId" placeholder="选择有效合格实收"><el-option v-for="receipt in sources" :key="receipt.id" :value="receipt.id" :label="`${receipt.receiptNo} · 合格 ${receipt.goodQuantity} 件`" /></el-select></el-form-item><el-form-item label="本箱分配件数" required><el-input-number v-model="source.quantity" :min="1" :precision="0" placeholder="分配到此箱的件数" controls-position="right" /></el-form-item><el-button class="density-action" data-tone="danger" @click="box.sources.splice(sourceIndex, 1)">移除来源</el-button></div>
<div v-for="(source, sourceIndex) in box.sources" :key="sourceIndex" class="packing-source"><el-form-item :label="t('海外实收来源')" required><el-select v-model="source.receiptId" placeholder="选择有效合格实收"><el-option v-for="receipt in sources" :key="receipt.id" :value="receipt.id" :label="`${receipt.receiptNo} · 合格 ${receipt.goodQuantity} 件`" /></el-select></el-form-item><el-form-item label="本箱分配件数" required><el-input-number v-model="source.quantity" :min="1" :precision="0" placeholder="分配到此箱的件数" controls-position="right" /></el-form-item><el-button class="density-action" data-tone="danger" @click="box.sources.splice(sourceIndex, 1)">移除来源</el-button></div>
<el-button class="density-action" @click="box.sources.push({ receiptId: '', quantity: undefined })">增加来源</el-button>
</section><el-button class="density-action" data-tone="primary" @click="form.cartons.push(newCarton())">增加装货箱</el-button>
</template>
@@ -5,6 +5,8 @@ import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
import type { ProcurementCommand } from '../api/procurementCommands'
import { transportForm, transportCargo, type TransportForm } from '../procurementTransportForm'
import { dateTime, measurement, money } from '../procurementPresentation'
import { regionText } from '../procurementDirection'
const t = (text: string) => regionText(text, props.detail.customerView.order.procurementDirection)
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[] }>()
const form = defineModel<TransportForm>({ required: true })
const cargo = computed(() => transportCargo(props.detail, form.value.leg))
@@ -19,8 +21,8 @@ function selectCarton(id: string) { const box = props.detail.transport.domesticC
<div class="admin-density">
<template v-if="command === 'labels'">
<el-form-item label="面单运输段" required><el-radio-group v-model="form.leg" @change="changeLeg"><el-radio value="OVERSEAS" :disabled="!!detail.purchaseFacts.overseas_shipped_at">国际段</el-radio><el-radio value="DOMESTIC" :disabled="!detail.purchaseFacts.domestic_received_at">寄客户段</el-radio></el-radio-group></el-form-item>
<p>寄件资料:{{ sender ? `${sender.name} · ${sender.phone} · ${sender.address}` : '由实际海外收货仓主档提供,保存时核对。' }}</p>
<div class="transport-grid"><el-form-item :label="form.leg === 'OVERSEAS' ? '国内接收人' : '最终收件人'" required><el-input v-model="form.receiver.name" maxlength="128" /></el-form-item><el-form-item label="联系电话" required><el-input v-model="form.receiver.phone" maxlength="64" /></el-form-item></div>
<p>寄件资料:{{ sender ? `${sender.name} · ${sender.phone} · ${sender.address}` : t('由实际海外收货仓主档提供,保存时核对。') }}</p>
<div class="transport-grid"><el-form-item :label="form.leg === 'OVERSEAS' ? t('国内接收人') : '最终收件人'" required><el-input v-model="form.receiver.name" maxlength="128" /></el-form-item><el-form-item label="联系电话" required><el-input v-model="form.receiver.phone" maxlength="64" /></el-form-item></div>
<el-form-item label="完整收件地址" required><el-input v-model="form.receiver.address" maxlength="1000" /></el-form-item>
<el-form-item v-if="changedReceiver" label="客户确认收件信息变更凭证" required><el-select v-model="form.addressVoucherFileId" placeholder="选择本单已上传的客户确认凭证"><el-option v-for="file in files.filter(item => item.category === 'EXCEPTION')" :key="file.fileId" :label="file.fileName" :value="file.fileId" /></el-select></el-form-item>
<section v-for="row in form.labels" :key="row.cartonId" class="label-row">
@@ -39,8 +41,8 @@ function selectCarton(id: string) { const box = props.detail.transport.domesticC
<el-form-item label="实际贴单时间" required><el-date-picker v-model="form.occurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
</template>
<template v-if="command === 'domestic-repack'">
<el-alert title="只允许原箱一对一换包装,不改变货物来源和件数。保存后旧国内面单失效,须重新制单并核对国内费用。" type="warning" :closable="false" />
<el-form-item label="换包装原箱" required><el-select v-model="form.cartonId" placeholder="选择国内可寄原箱" @change="selectCarton"><el-option v-for="box in cargo" :key="box.cartonId" :value="box.cartonId" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<el-alert :title="t('只允许原箱一对一换包装,不改变货物来源和件数。保存后旧国内面单失效,须重新制单并核对国内费用。')" type="warning" :closable="false" />
<el-form-item label="换包装原箱" required><el-select v-model="form.cartonId" :placeholder="t('选择国内可寄原箱')" @change="selectCarton"><el-option v-for="box in cargo" :key="box.cartonId" :value="box.cartonId" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<div class="measure-grid"><el-form-item label="新长度(cm)" required><el-input v-model="form.lengthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新宽度(cm)" required><el-input v-model="form.widthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新高度(cm)" required><el-input v-model="form.heightCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新毛重(kg)" required><el-input v-model="form.grossWeightKg" placeholder="最多三位小数" /></el-form-item></div>
</template>
<template v-if="['overseas-shipment', 'customer-shipment'].includes(command)">
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ElementPlus from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ProcurementWorkflowPanel from './ProcurementWorkflowPanel.vue'
import type { PlatformProcurement } from '../procurementTypes'
import type { ProcurementWorkflow } from '../api/procurementWorkflow'
import { submitProcurementWorkflow } from '../api/procurementWorkflow'
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
vi.mock('../api/procurementWorkflow', () => ({ submitProcurementWorkflow: vi.fn() }))
vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() }))
vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() }))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
enableAutoUnmount(afterEach)
const workflow = (status = 'RECEIVING'): ProcurementWorkflow => ({ orderNo: 'OP-1', version: 4, status, receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] })
const detail = { customerView: { order: { purchaseQuantity: 20 } }, files: [] } as unknown as PlatformProcurement
const beforeOutbound = vi.fn<() => Promise<void>>()
function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-customer']) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'test'; auth.permissions = permissions.map(value => `platform:overseas-business:purchase:${value}`); useAccessPreviewStore().initializeAfterLogin()
return mount(ProcurementWorkflowPanel, { props: { detail, workflow: workflow(status), customerId: '1001', beforeOutbound }, global: { plugins: [pinia, ElementPlus] } })
}
beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(undefined); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()); vi.mocked(listOverseasPurchaseWarehouseOptions).mockResolvedValue([]) })
describe('simplified procurement operations', () => {
it('blocks the outbound dialog when fees are missing or not approved and permits retry after approval', async () => {
const wrapper = render('CONFIRMED')
const button = wrapper.findAll('button').find(button => button.text() === '创建出库单')!
beforeOutbound.mockRejectedValueOnce(new Error('请先添加费用明细并审核通过后再出库'))
await button.trigger('click'); await flushPromises()
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
expect(submitProcurementWorkflow).not.toHaveBeenCalled()
beforeOutbound.mockRejectedValueOnce(new Error('费用明细尚未全部审核通过'))
await button.trigger('click'); await flushPromises()
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
await button.trigger('click'); await flushPromises()
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
})
it('gates receipt and outbound actions with the existing operation permissions', () => {
const receipt = render('RECEIVING', [])
expect(receipt.findAll('button').find(button => button.text() === '登记收货')!.attributes('disabled')).toBeDefined()
expect(receipt.text()).toContain('当前账号未开通采购收货权限')
const outbound = render('CONFIRMED', [])
expect(outbound.findAll('button').find(button => button.text() === '创建出库单')!.attributes('disabled')).toBeDefined()
expect(outbound.text()).toContain('当前账号未开通采购出库及签收权限')
expect(render('CONFIRMED').text()).toContain('创建出库单')
})
it('requires receipt photos and uploads them before submitting the current batch quantity', async () => {
const wrapper = render(); await wrapper.findAll('button').find(button => button.text() === '登记收货')!.trigger('click')
const dialog = wrapper.findComponent(AppDialog)
dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).not.toHaveBeenCalled()
vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '1', fileId: '9007199254740993', category: 'RECEIPT', fileName: 'receipt.png', contentType: 'image/png', sizeBytes: 10, createdAt: '' })
const input = document.querySelector('input[type="file"]') || wrapper.find('input[type="file"]').element
Object.defineProperty(input, 'files', { configurable: true, value: [new File(['photo'], 'receipt.png', { type: 'image/png' })] })
input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'receipts', { version: 4, idempotencyKey: expect.any(String), quantity: 1, fileIds: ['9007199254740993'] })
})
it('retains the outbound input and idempotency key after a failed submit', async () => {
vi.mocked(submitProcurementWorkflow).mockRejectedValue(new Error('暂时失败'))
const wrapper = render('CONFIRMED'); await wrapper.findAll('button').find(button => button.text() === '创建出库单')!.trigger('click')
await flushPromises()
const dialog = wrapper.findComponent(AppDialog), form = wrapper.findComponent({ name: 'ElForm' }).props('model')
form.trackingNo = 'TRACK-1'; form.quantity = 3
dialog.vm.$emit('confirm'); await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2)
expect(vi.mocked(submitProcurementWorkflow).mock.calls[0]![3]).toEqual(vi.mocked(submitProcurementWorkflow).mock.calls[1]![3])
expect(form.trackingNo).toBe('TRACK-1'); expect(form.quantity).toBe(3)
form.quantity = 6; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2)
})
it('requires a rejection reason and preserves the current version and customer scope', async () => {
const wrapper = render('PENDING_REVIEW', ['review'])
await wrapper.findAll('button').find(button => button.text() === '审核')!.trigger('click'); await flushPromises()
const form = wrapper.findComponent({ name: 'ElForm' }).props('model'), dialog = wrapper.findComponent(AppDialog)
form.decision = 'REJECT'; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementCommand).not.toHaveBeenCalled()
form.reason = '商品无法采购'; dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-1', '1001', 'review', expect.objectContaining({ version: 4, decision: 'REJECT', reason: '商品无法采购', purchaseWarehouseId: undefined }))
})
it('only completes the selected delivering batch after signature evidence is supplied', async () => {
const wrapper = render('DELIVERING')
await wrapper.setProps({ workflow: { ...workflow('DELIVERING'), outbounds: [{ id: '9007199254740993', outboundNo: 'OUT-1', status: 'DELIVERING', quantity: 3, trackingNo: 'TRACK-1', fileIds: [], createdAt: '', completedAt: null }] } })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '上传签收凭证')!.trigger('click'); await flushPromises()
const form = wrapper.findComponent({ name: 'ElForm' }).props('model'), dialog = wrapper.findComponent(AppDialog)
dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).not.toHaveBeenCalled()
vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '2', fileId: '9007199254740995', category: 'RECEIPT', fileName: 'signed.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' })
const input = document.querySelector('input[type="file"]') || wrapper.find('input[type="file"]').element
expect(input.getAttribute('accept')).toContain('application/pdf')
Object.defineProperty(input, 'files', { configurable: true, value: [new File(['signed'], 'signed.pdf', { type: 'application/pdf' })] })
input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
expect(uploadProcurementFile).toHaveBeenCalledWith('OP-1', '1001', 'RECEIPT', expect.any(File))
expect(form.fileIds).toEqual(['9007199254740995'])
dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'outbounds/9007199254740993/complete', { version: 4, idempotencyKey: expect.any(String), fileIds: ['9007199254740995'] })
})
})
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
import { submitProcurementWorkflow, type ProcurementWorkflow, type ProcurementOutbound, type ProcurementWorkflowReceipt } from '../api/procurementWorkflow'
import { dateTime, statusLabel } from '../procurementPresentation'
import type { PlatformProcurement } from '../procurementTypes'
import type { OverseasPurchaseWarehouseOption } from '../types'
const props = defineProps<{ detail: PlatformProcurement; workflow: ProcurementWorkflow; customerId: string; downloading?: string; beforeOutbound: () => Promise<void> }>()
const emit = defineEmits<{ changed: []; download: [id: string] }>()
const access = useAccessPreviewStore()
const open = ref(false), saving = ref(false), uploading = ref(false), checkingFees = ref(false), error = ref<ApiErrorDetails | null>(null)
const action = ref(''), batchId = ref(''), warehouses = ref<OverseasPurchaseWarehouseOption[]>([]), warehouseLoading = ref(false)
const form = reactive({ quantity: 1, trackingNo: '', fileIds: [] as string[], warehouseId: '', decision: 'APPROVE' as 'APPROVE' | 'REJECT', reason: '' })
const uploadedNames = ref<string[]>([])
const title = computed(() => ({ review: '审核采购单', receipts: '登记收货', 'request-confirmation': '采购完成', outbounds: '创建出库单', dispatch: '开始派送', complete: '上传签收凭证' })[action.value] || '采购操作')
const permission = computed(() => `platform:overseas-business:purchase:${action.value === 'review' ? 'review' : ['receipts', 'request-confirmation'].includes(action.value) ? 'receive-overseas' : 'ship-customer'}`)
const can = (name: string) => access.can(`platform:overseas-business:purchase:${name}`)
const permissionHint = computed(() => props.workflow.status === 'RECEIVING' && !can('receive-overseas')
? '当前账号未开通采购收货权限,请联系管理员开通。'
: ['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(props.workflow.status) && !can('ship-customer')
? '当前账号未开通采购出库及签收权限,请联系管理员开通。' : '')
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'procurement-workflow', label: title, isOpen: open, model: form, requiredPermission: permission })
let fingerprint = '', requestKey = '', openedScope = '', dialogGeneration = 0
const scope = () => `${props.customerId}:${props.workflow.orderNo}`
watch(scope, () => { dialogGeneration++; open.value = false; error.value = null })
watch(open, value => { if (!value) dialogGeneration++ })
async function begin(name: string, batch?: ProcurementOutbound) {
if (saving.value || uploading.value || checkingFees.value) return
const required = name === 'review' ? 'review' : ['receipts', 'request-confirmation'].includes(name) ? 'receive-overseas' : 'ship-customer'
if (!can(required)) return
const current = ++dialogGeneration
if (name === 'outbounds') {
checkingFees.value = true
try { await props.beforeOutbound() }
catch (failure) { if (current === dialogGeneration) ElMessage.warning(getApiErrorDetails(failure, '费用校验失败').message); return }
finally { checkingFees.value = false }
if (current !== dialogGeneration) return
}
action.value = name; batchId.value = batch?.id || ''; error.value = null; warehouseLoading.value = false
Object.assign(form, { quantity: 1, trackingNo: '', fileIds: [], warehouseId: '', decision: 'APPROVE', reason: '' }); uploadedNames.value = []
fingerprint = ''; requestKey = ''; openedScope = scope(); open.value = true; captureBaseline()
if (name === 'review') {
warehouseLoading.value = true
try { const result = await listOverseasPurchaseWarehouseOptions(props.detail.customerView.order.procurementDirection); if (current === dialogGeneration) warehouses.value = result }
catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '仓库加载失败') }
finally { if (current === dialogGeneration) warehouseLoading.value = false }
}
}
async function upload(event: Event) {
const input = event.target as HTMLInputElement; const files = Array.from(input.files || []); input.value = ''
if (!files.length || uploading.value) return
const current = dialogGeneration; const orderNo = props.workflow.orderNo; const customer = props.customerId; uploading.value = true; error.value = null
try {
for (const file of files) {
if (action.value === 'receipts' && !file.type.startsWith('image/')) throw new Error('收货凭证请选择图片')
const result = await uploadProcurementFile(orderNo, customer, 'RECEIPT', file)
if (current !== dialogGeneration) return
form.fileIds.push(result.fileId); uploadedNames.value.push(file.name)
}
} catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '上传失败') }
finally { uploading.value = false }
}
async function save() {
if (saving.value || uploading.value || openedScope !== scope() || !access.can(permission.value)) return
if (action.value === 'review' && form.decision === 'APPROVE' && !form.warehouseId) { ElMessage.warning('请选择收货仓'); return }
if (action.value === 'review' && form.decision === 'REJECT' && !form.reason.trim()) { ElMessage.warning('请填写拒绝原因'); return }
if (['receipts', 'outbounds'].includes(action.value) && (!Number.isSafeInteger(form.quantity) || form.quantity < 1)) { ElMessage.warning('请输入有效数量'); return }
if (action.value === 'receipts' && form.quantity > props.detail.customerView.order.purchaseQuantity - props.workflow.receivedQuantity) { ElMessage.warning('收货数量不能超过剩余采购数量'); return }
if (action.value === 'outbounds' && (!form.trackingNo.trim() || form.quantity > props.workflow.availableQuantity)) { ElMessage.warning('请填写物流跟踪号,数量不能超过可出库库存'); return }
if (['receipts', 'complete'].includes(action.value) && !form.fileIds.length) { ElMessage.warning(action.value === 'receipts' ? '请上传收货照片' : '请上传签收文件或图片'); return }
const data = { version: props.workflow.version, ...(action.value === 'receipts' ? { quantity: form.quantity, fileIds: [...form.fileIds] } : action.value === 'outbounds' ? { quantity: form.quantity, trackingNo: form.trackingNo.trim() } : action.value === 'complete' ? { fileIds: [...form.fileIds] } : {}) }
const next = JSON.stringify([action.value, batchId.value, form.warehouseId, form.decision, form.reason, data])
if (fingerprint !== next || !requestKey) { fingerprint = next; requestKey = crypto.randomUUID() }
const current = dialogGeneration
saving.value = true; error.value = null
try {
if (action.value === 'review') await submitProcurementCommand(props.workflow.orderNo, props.customerId, 'review', { version: props.workflow.version, idempotencyKey: requestKey, decision: form.decision, purchaseWarehouseId: form.decision === 'APPROVE' ? form.warehouseId : undefined, reason: form.reason.trim() || undefined })
else await submitProcurementWorkflow(props.workflow.orderNo, props.customerId, batchId.value ? `outbounds/${encodeURIComponent(batchId.value)}/${action.value}` : action.value, { ...data, idempotencyKey: requestKey })
if (current !== dialogGeneration) return
captureBaseline(); open.value = false; ElMessage.success('操作成功'); emit('changed')
} catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '操作失败') }
finally { saving.value = false }
}
const receiptColumns: DataTableColumn[] = [{ prop: 'quantity', label: '收货数量', width: 140 }, { key: 'receivedAt', label: '收货时间', minWidth: 170, formatter: (row: ProcurementWorkflowReceipt) => dateTime(row.receivedAt) }, { key: 'files', label: '照片', minWidth: 180, slotName: 'files' }]
const outboundColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, slotName: 'actions', fixed: 'left' }, { prop: 'outboundNo', label: '出库单号', minWidth: 190 },
{ prop: 'quantity', label: '出库数量', width: 110 }, { prop: 'trackingNo', label: '物流跟踪号', minWidth: 190 },
{ key: 'status', label: '状态', width: 120, formatter: (row: ProcurementOutbound) => statusLabel(row.status) },
{ key: 'createdAt', label: '创建时间', minWidth: 170, formatter: (row: ProcurementOutbound) => dateTime(row.createdAt) },
{ key: 'completedAt', label: '签收时间', minWidth: 170, formatter: (row: ProcurementOutbound) => dateTime(row.completedAt) },
{ key: 'files', label: '签收凭证', minWidth: 170, slotName: 'files' },
]
const fileName = (id: string) => props.detail.files.find(file => file.fileId === id)?.fileName || id
</script>
<template>
<section class="procurement-workflow">
<div class="density-actions">
<el-button v-if="workflow.status === 'PENDING_REVIEW' && can('review')" type="primary" @click="begin('review')">审核</el-button>
<template v-if="workflow.status === 'RECEIVING'"><el-button type="primary" :disabled="!can('receive-overseas') || workflow.receivedQuantity >= detail.customerView.order.purchaseQuantity" @click="begin('receipts')">登记收货</el-button><el-button :disabled="!can('receive-overseas') || !workflow.receivedQuantity" @click="begin('request-confirmation')">采购完成</el-button></template>
<el-button v-if="['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(workflow.status)" type="primary" :loading="checkingFees" :disabled="!can('ship-customer') || workflow.availableQuantity < 1" @click="begin('outbounds')">创建出库单</el-button>
</div>
<el-text v-if="permissionHint" type="warning" role="status">{{ permissionHint }}</el-text>
<dl class="density-fields inventory"><div><dt>累计收货</dt><dd>{{ workflow.receivedQuantity }}</dd></div><div><dt>采购库存</dt><dd>{{ workflow.stockQuantity }}</dd></div><div><dt>出库处理中</dt><dd>{{ workflow.reservedQuantity }}</dd></div><div><dt>可出库</dt><dd>{{ workflow.availableQuantity }}</dd></div></dl>
<h3>收货记录</h3><DataTable :data="workflow.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收货记录"><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<h3>出库单</h3><DataTable :data="workflow.outbounds" :columns="outboundColumns" row-key="id" empty-text="暂无出库单"><template #actions="{ row }"><el-button v-if="row.status === 'IN_TRANSIT'" :disabled="!can('ship-customer')" title="开始本批次派送" link type="primary" @click="begin('dispatch', row as ProcurementOutbound)">开始派送</el-button><el-button v-if="row.status === 'DELIVERING'" :disabled="!can('ship-customer')" title="上传图片或文件并确认本批次签收" link type="primary" @click="begin('complete', row as ProcurementOutbound)">上传签收凭证</el-button></template><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<AppDialog v-model="open" :title="title" width="580px" confirm-text="确认" :confirm-loading="saving" :confirm-disabled="uploading || warehouseLoading" @confirm="save">
<el-form :model="form" class="density-form" label-position="top" :disabled="saving">
<template v-if="action === 'review'"><el-form-item 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="form.decision === 'APPROVE'" label="收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehouseLoading"><el-option v-for="warehouse in warehouses" :key="warehouse.warehouseId" :value="warehouse.warehouseId" :label="warehouse.warehouseName" /></el-select></el-form-item><el-form-item v-else label="拒绝原因" required><el-input v-model="form.reason" type="textarea" :rows="2" maxlength="1000" /></el-form-item></template>
<el-form-item v-if="['receipts', 'outbounds'].includes(action)" :label="action === 'receipts' ? '本批收货数量' : '本批出库数量'" required><el-input-number v-model="form.quantity" :min="1" :max="action === 'outbounds' ? workflow.availableQuantity : detail.customerView.order.purchaseQuantity - workflow.receivedQuantity" :precision="0" /></el-form-item>
<el-form-item v-if="action === 'outbounds'" label="物流跟踪号" required><el-input v-model="form.trackingNo" maxlength="100" /></el-form-item>
<el-form-item v-if="['receipts', 'complete'].includes(action)" :label="action === 'receipts' ? '收货照片' : '签收文件或图片'" required><input type="file" multiple :accept="action === 'receipts' ? 'image/*' : 'image/*,application/pdf'" :disabled="saving || uploading" @change="upload" /><div v-for="name in uploadedNames" :key="name">{{ name }}</div></el-form-item>
<p v-if="action === 'request-confirmation'">确认采购完成并提交客户确认?</p><p v-if="action === 'dispatch'">确认本批次开始派送?</p>
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
</AppDialog>
</section>
</template>
<style scoped>
.inventory { margin: var(--admin-space-4) 0; }
h3 { margin: var(--admin-space-4) 0 var(--admin-space-3); font-size: var(--admin-font-section); }
</style>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { actOnOverseasFee, type OverseasFee } from '../api/overseasFees'
import { adjustStorageRentFee } from '../api/storageRent'
import { feePermissions, feeMoney } from '../overseasFeePresentation'
import StorageRentFeeDetails from './StorageRentFeeDetails.vue'
const props = defineProps<{ fee: OverseasFee | null; action: string; readonly?: boolean }>()
const open = defineModel<boolean>({ required: true })
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const amount = ref(0), reason = ref(''), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const key = ref(''), fingerprint = ref('')
const adjusting = computed(() => props.action === 'ADJUST_RENT')
const allowed = (action: string) => !props.readonly && props.fee?.allowedActions.includes(action) && access.can(action === 'ADJUST_RENT' ? feePermissions.edit : feePermissions.review)
function reset() { amount.value = props.fee?.originalAmount ?? 0; reason.value = ''; error.value = null; key.value = ''; fingerprint.value = '' }
watch(open, value => { if (value) reset() }, { immediate: true })
async function submit(decision?: 'APPROVE' | 'REJECT') {
const fee = props.fee, action = adjusting.value ? 'ADJUST_RENT' : decision
if (!fee || !action || !allowed(action) || busy.value) return
if ((adjusting.value || decision === 'REJECT') && !reason.value.trim()) { ElMessage.warning('请填写说明'); return }
if (adjusting.value && (!Number.isFinite(amount.value) || amount.value < 0)) { ElMessage.warning('请输入有效金额'); return }
const body = adjusting.value ? { expectedVersion: fee.sourceVersion, amount: amount.value, reason: reason.value.trim() }
: { decision, reason: reason.value.trim() || undefined, expectedVersion: fee.sourceVersion }
const next = JSON.stringify([fee.sourceFeeId, body])
if (fingerprint.value !== next) { fingerprint.value = next; key.value = crypto.randomUUID() }
busy.value = true; error.value = null
try {
if (adjusting.value) await adjustStorageRentFee(fee.sourceFeeId, { expectedVersion: fee.sourceVersion, amount: amount.value, reason: reason.value.trim(), idempotencyKey: key.value })
else await actOnOverseasFee(fee.sourceFeeId, decision!, { ...body, idempotencyKey: key.value })
open.value = false; ElMessage.success(adjusting.value ? '仓租已调整' : decision === 'APPROVE' ? '审核通过,正在处理扣款' : '仓租已驳回'); emit('saved')
} catch (failure) { error.value = getApiErrorDetails(failure, '仓租操作失败') }
finally { busy.value = false }
}
</script>
<template>
<AppDialog v-model="open" append-to-body :title="adjusting ? '调整仓租' : '审核仓租'" width="760px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" :confirm-loading="busy" lock-while-submitting>
<template v-if="fee">
<p>{{ fee.businessNo }} · 仓租 {{ feeMoney(fee.originalAmount, fee.originalCurrency) }}</p>
<StorageRentFeeDetails :fee="fee" />
<p v-if="fee.feeRemark">费用说明:{{ fee.feeRemark }}</p>
<el-form label-position="top" :disabled="busy" class="density-form">
<el-form-item v-if="adjusting" :label="`仓租金额(${fee.originalCurrency})`" required><el-input-number v-model="amount" :precision="2" :min="0" :max="999999999999.99" /></el-form-item>
<el-form-item :label="adjusting ? '调整说明' : '审核说明(驳回必填)'" :required="adjusting"><el-input v-model="reason" type="textarea" :maxlength="500" show-word-limit /></el-form-item>
</el-form>
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
</template>
<template #footer>
<el-button :disabled="busy" @click="open = false">关闭</el-button>
<template v-if="adjusting"><el-button v-if="allowed('ADJUST_RENT')" type="primary" :loading="busy" :disabled="!reason.trim()" @click="submit()">保存调整</el-button></template>
<template v-else>
<el-button v-if="allowed('REJECT')" type="danger" :disabled="busy || !reason.trim()" @click="submit('REJECT')">驳回</el-button>
<el-button v-if="allowed('APPROVE')" type="primary" :loading="busy" @click="submit('APPROVE')">审核通过</el-button>
</template>
</template>
</AppDialog>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import '../overseasFeeDetails.css'
import type { OverseasFee } from '../api/overseasFees'
import { feeMoney } from '../overseasFeePresentation'
import { storageRentSourceType, storageRentTime } from '../storageRentPresentation'
defineProps<{ fee: OverseasFee }>()
</script>
<template>
<section v-if="fee.storageRent" aria-label="仓租计费明细">
<el-descriptions :column="2" border class="density-descriptions overseas-fee-descriptions">
<el-descriptions-item label="出库批次">{{ fee.storageRent.outboundNo }}</el-descriptions-item>
<el-descriptions-item label="出库时间">{{ storageRentTime(fee.storageRent.outboundAt) }}</el-descriptions-item>
<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>
<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>
<el-table-column label="来源单号" min-width="180"><template #default="{ row }">{{ row.sourceBusinessNo || '—' }}</template></el-table-column>
<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 label="原计算仓租" min-width="130"><template #default="{ row }">{{ feeMoney(row.calculatedAmount, fee.originalCurrency) }}</template></el-table-column>
</el-table>
</section>
</template>
@@ -0,0 +1,103 @@
import { createRequire } from 'node:module'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import * as api from '../api/storageRent'
import type { StorageRentLot } from '../api/storageRent'
import StorageRentPanel from './StorageRentPanel.vue'
const { default: ElementPlus } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
vi.mock('../api/storageRent', () => ({ getStorageRentLot: vi.fn(), listStorageRentLots: vi.fn(), listStorageRentWarehouses: vi.fn() }))
const lot: StorageRentLot = {
id: '9223372036854775001', customerId: '1', customerName: '测试客户', businessType: 'LASTLEG_ORDER', businessNo: 'FL1',
warehouseId: '2', warehouseName: '测试仓', batchNo: 'B1', inboundAt: '2026-10-22T00:00:00Z', storageDays: 1,
originalQuantity: 10, remainingQuantity: 8, quantityUnit: 'PACKAGE', billingQuantity: 1, billingUnit: 'CBM',
ruleCode: 'TRANSIT_V1', status: 'IN_STOCK', generatedAmount: 0, generatedCurrency: null, generatedAmounts: [],
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.resetAllMocks()
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(api.listStorageRentLots).mockResolvedValue({ records: [lot], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.listStorageRentWarehouses).mockResolvedValue([{ id: '2', warehouseName: '测试仓' }])
vi.mocked(api.getStorageRentLot).mockResolvedValue({ lot, charges: [{
id: '3', outboundNo: 'OUT1', businessNo: 'FL1', quantity: 2, storageDays: 1, originalAmount: 0, currency: 'USD',
settlementAmount: 0, currencyRate: 7, status: 'SETTLED', createdAt: '2026-10-22T01:00:00Z', outboundAt: '2026-10-22T01:00:00Z',
}] })
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('仓租列表服务端契约', () => {
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 })
vi.mocked(api.getStorageRentLot).mockResolvedValue({ lot: returned, charges: ['TH1', 'CD1'].map((businessNo, index) => ({ id: String(index), outboundNo: `OUT${index}`, businessNo, quantity: 2, storageDays: 1, originalAmount: 0.75, currency: 'USD', settlementAmount: 5, currencyRate: 6.67, status: 'SETTLED', createdAt: '', outboundAt: '' })) })
const wrapper = mount(StorageRentPanel, { attachTo: document.body, props: { customers: [] }, global: { plugins: [ElementPlus] } })
await flushPromises()
const body = wrapper.get('.el-table__body-wrapper')
expect(body.text()).toContain('2 件')
expect(wrapper.text()).toContain('库龄(天)')
expect(wrapper.text()).toContain('来源单号')
expect(body.text()).toContain('TH1')
expect(wrapper.text()).not.toContain('仓租 / 扣款')
await body.findAll('button').find(button => button.text().trim() === '详情')!.trigger('click'); await flushPromises()
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('0.5 托')
expect(dialog.textContent).toContain('OUT0')
expect(dialog.textContent).toContain('OUT1')
expect(dialog.textContent).not.toContain('USD')
expect(dialog.textContent).not.toContain('审核')
expect(dialog.textContent).toContain('CD1')
expect(dialog.textContent).toContain('来源单号')
expect(dialog.textContent).toContain('出库业务单号')
})
it('displays and filters first-leg lots using LASTLEG_ORDER and retains inbound and outbound evidence', async () => {
const wrapper = mount(StorageRentPanel, { attachTo: document.body, props: { customers: [] }, global: { plugins: [ElementPlus] } })
await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
expect(api.listStorageRentLots).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'IN_STOCK', page: 1 }))
expect(toolbar.props('fields').some(field => field.key === 'status')).toBe(false)
const options = toolbar.props('fields').find(field => field.key === 'businessType')?.options
expect(options).toContainEqual({ value: 'LASTLEG_ORDER', label: '头程入仓' })
expect(options).not.toContainEqual({ value: 'FIRST_LEG', label: '头程入仓' })
expect(wrapper.get('.el-table__body-wrapper').text()).toContain('头程入仓')
toolbar.vm.$emit('search', { businessType: 'LASTLEG_ORDER' })
await flushPromises()
expect(api.listStorageRentLots).toHaveBeenLastCalledWith(expect.objectContaining({ businessType: 'LASTLEG_ORDER', status: 'IN_STOCK', page: 1 }))
toolbar.vm.$emit('search', {})
await flushPromises()
expect(api.listStorageRentLots).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'IN_STOCK', page: 1 }))
const detailButton = wrapper.findAll('.el-table__body-wrapper button').find(button => button.text().trim() === '详情')
await detailButton!.trigger('click'); await flushPromises()
expect(api.getStorageRentLot).toHaveBeenCalledWith(lot.id)
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('2026-10-22')
expect(dialog.textContent).toContain('8 箱')
expect(dialog.textContent).toContain('OUT1')
expect(dialog.textContent).not.toContain('实扣金额')
})
it('keeps inventory focused on age and outbound history even when charges use different currencies', async () => {
const mixedLot = { ...lot, generatedAmount: null, generatedCurrency: null, generatedAmounts: [{ currency: 'USD', amount: 12 }, { currency: 'CNY', amount: 23 }] }
vi.mocked(api.listStorageRentLots).mockResolvedValue({ records: [mixedLot], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getStorageRentLot).mockResolvedValue({ lot: mixedLot, charges: [
{ id: '3', outboundNo: 'OUT1', businessNo: 'FL1', quantity: 1, storageDays: 8, originalAmount: 12, currency: 'USD', settlementAmount: 84, currencyRate: 7, status: 'SETTLED', createdAt: '2026-10-22T01:00:00Z', outboundAt: '2026-10-22T01:00:00Z' },
{ id: '4', outboundNo: 'OUT2', businessNo: 'FL1', quantity: 1, storageDays: 9, originalAmount: 23, currency: 'CNY', settlementAmount: 23, currencyRate: 1, status: 'SETTLED', createdAt: '2026-10-22T02:00:00Z', outboundAt: '2026-10-22T02:00:00Z' },
] })
const wrapper = mount(StorageRentPanel, { attachTo: document.body, props: { customers: [] }, global: { plugins: [ElementPlus] } })
await flushPromises()
const body = wrapper.get('.el-table__body-wrapper')
expect(body.text()).toContain('8 箱')
expect(body.text()).not.toContain('USD')
expect(body.text()).not.toContain('CNY')
await body.findAll('button').find(button => button.text().trim() === '详情')!.trigger('click'); await flushPromises()
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('OUT1')
expect(dialog.textContent).toContain('OUT2')
expect(dialog.textContent).toContain('计租天数')
const rows = Array.from(dialog.querySelectorAll('.el-table__body-wrapper tbody tr'))
expect(rows[0]?.textContent).toContain('8')
expect(rows[1]?.textContent).toContain('9')
expect(dialog.textContent).not.toContain('USD')
expect(dialog.textContent).not.toContain('CNY')
})
})
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getStorageRentLot, listStorageRentLots, listStorageRentWarehouses, type StorageRentCharge, type StorageRentDetail, type StorageRentLot } from '../api/storageRent'
import { storageRentBillingShare, storageRentTime } from '../storageRentPresentation'
const props = defineProps<{ customers: Array<{ customerId: string; customerName: string }> }>()
const rows = ref<StorageRentLot[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const warehouses = ref<Array<{ id: string; warehouseName: string }>>([])
const warehouseError = ref<ApiErrorDetails | null>(null), warehouseLoading = ref(false)
const page = ref(1), pageSize = ref(20), total = ref(0), detail = ref<StorageRentDetail | null>(null), detailOpen = ref(false)
const detailLoading = ref('')
const filters = ref<QueryModel>({ customerId: '', businessNo: '', warehouseId: '', businessType: '' }), applied = ref({ ...filters.value })
const typeLabels: Record<string, string> = { LASTLEG_ORDER: '头程入仓', WAREHOUSE_TRANSFER: '仓储转运', RETURN_ORDER: '退货', RELABEL_ORDER: '换标' }
const unitLabels: Record<string, string> = { PACKAGE: '箱', PIECE: '件', CBM: 'CBM', PALLET: '托' }
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '客户', options: props.customers.map(row => ({ label: row.customerName, value: row.customerId })), props: { filterable: true, 'aria-label': '客户' } },
{ key: 'businessNo', type: 'input', label: '来源单号', placeholder: '来源单号', width: 220, props: { 'aria-label': '来源单号' } },
{ key: 'warehouseId', type: 'select', label: '海外仓', placeholder: '海外仓', options: warehouses.value.map(row => ({ value: String(row.id), label: row.warehouseName })), props: { filterable: true, 'aria-label': '海外仓', loading: warehouseLoading.value } },
{ key: 'businessType', type: 'select', label: '业务类型', placeholder: '业务类型', options: Object.entries(typeLabels).map(([value, label]) => ({ value, label })), props: { 'aria-label': '业务类型' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 85, fixed: 'left', slotName: 'actions' },
{ prop: 'customerName', label: '客户', minWidth: 140 }, { prop: 'businessNo', label: '来源单号', minWidth: 180 },
{ prop: 'businessType', label: '业务类型', width: 110, formatter: (row: StorageRentLot) => typeLabels[row.businessType] || row.businessType },
{ prop: 'warehouseName', label: '海外仓', minWidth: 140 }, { prop: 'batchNo', label: '批次号', minWidth: 150 },
{ prop: 'inboundAt', label: '入库时间', minWidth: 170, formatter: (row: StorageRentLot) => storageRentTime(row.inboundAt) },
{ prop: 'storageDays', label: '库龄(天)', width: 100, align: 'right' },
{ key: 'remaining', label: '剩余数量', width: 110, formatter: (row: StorageRentLot) => `${row.remainingQuantity} ${unitLabels[row.quantityUnit] || row.quantityUnit}` },
{ key: 'billing', label: '计租数量', width: 120, formatter: (row: StorageRentLot) => `${row.billingQuantity} ${unitLabels[row.billingUnit] || row.billingUnit}` },
]
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) : '—' },
{ prop: 'outboundAt', label: '出库时间', minWidth: 165, formatter: (row: { outboundAt: string }) => storageRentTime(row.outboundAt) },
])
let sequence = 0
async function load() {
const current = ++sequence; loading.value = true; error.value = null
try {
const query = Object.fromEntries(Object.entries(applied.value).map(([key, value]) => [key, value ? String(value) : undefined]))
const data = await listStorageRentLots({ ...query, status: 'IN_STOCK', page: page.value, size: pageSize.value })
if (current === sequence) { rows.value = data.records; total.value = data.total }
} catch (failure) { if (current === sequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(failure, '仓租库存加载失败') } }
finally { if (current === sequence) loading.value = false }
}
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
async function openDetail(row: StorageRentLot) {
if (detailLoading.value) return
detailLoading.value = row.id
try { detail.value = await getStorageRentLot(row.id); detailOpen.value = true }
catch (failure) { ElMessage.error(getApiErrorDetails(failure, '仓租详情加载失败').message) }
finally { detailLoading.value = '' }
}
async function loadWarehouses() {
warehouseLoading.value = true; warehouseError.value = null
try { warehouses.value = await listStorageRentWarehouses() }
catch (failure) { warehouseError.value = getApiErrorDetails(failure, '仓库选项加载失败') }
finally { warehouseLoading.value = false }
}
watch([page, pageSize], () => void load())
onMounted(() => { void load(); void loadWarehouses() })
</script>
<template>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" appearance="integrated" toolbar-placement="filters" preference-key="overseas-storage-rent" row-key="id" empty-text="暂无仓租库存">
<template #filters><QueryToolbar v-model="filters" ariaLabel="仓租库存筛选" :fields="fields" :loading="loading" @search="search" /></template>
<template #notices>
<LoadErrorState v-if="error" title="仓租库存加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="仓库选项加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
</template>
<template #actions="{ row }"><RowActions text-actions :actions="[{ key: 'DETAIL', label: '详情', loading: detailLoading === (row as StorageRentLot).id, disabled: !!detailLoading }]" @action="openDetail(row as StorageRentLot)" /></template>
<template #pagination-left>共 {{ total }} 个批次</template>
</DataTable>
<AppDialog v-model="detailOpen" title="仓租详情" width="1080px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" cancel-text="关闭" fullscreenable>
<template v-if="detail">
<el-descriptions :column="3" border class="density-descriptions">
<el-descriptions-item label="客户">{{ detail.lot.customerName }}</el-descriptions-item><el-descriptions-item label="来源单号">{{ detail.lot.businessNo }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ detail.lot.warehouseName }}</el-descriptions-item>
<el-descriptions-item label="批次号">{{ detail.lot.batchNo }}</el-descriptions-item><el-descriptions-item label="入库时间">{{ storageRentTime(detail.lot.inboundAt) }}</el-descriptions-item><el-descriptions-item label="库龄">{{ detail.lot.storageDays }} 天</el-descriptions-item>
<el-descriptions-item label="入库数量">{{ detail.lot.originalQuantity }} {{ unitLabels[detail.lot.quantityUnit] }}</el-descriptions-item><el-descriptions-item label="剩余数量">{{ detail.lot.remainingQuantity }} {{ unitLabels[detail.lot.quantityUnit] }}</el-descriptions-item><el-descriptions-item label="计租规则">{{ detail.lot.ruleCode === 'RETURN_V1' ? '退货仓租' : '中转仓租' }}</el-descriptions-item>
</el-descriptions>
<div class="rent-charges"><DataTable :data="detail.charges" :columns="chargeColumns" :page-sizes="[20]" empty-text="暂无出库记录" max-height="360px" /></div>
</template>
</AppDialog>
</template>
<style scoped>
.rent-charges { margin-top: 16px; }
</style>
@@ -31,14 +31,14 @@ defineExpose({
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
</el-form-item>
<el-form-item label="起运地" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
<el-form-item label="起运地联系人" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" /></el-form-item>
<el-form-item label="目的地联系人" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" /></el-form-item>
<el-form-item label="起运地联系电话" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" /></el-form-item>
<el-form-item label="目的地联系电话" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></el-form-item>
<el-form-item label="起运详细地址"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
<el-form-item label="目的详细地址"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点取货地' : '起运地'" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点联系人' : '起运地联系人'" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点联系电话' : '起运地联系电话'" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点联系电话' : '目的地联系电话'" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点取货地址' : '起运详细地址'"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点送达地址' : '目的详细地址'"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
</div>
</FormSection>
<FormSection class="density-form-section" title="货物信息">
@@ -58,8 +58,8 @@ defineExpose({
<el-select v-model="model.currency" aria-label="预算币种" placeholder="请选择预算币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select>
</div></el-form-item>
<div />
<el-form-item label="取货时间" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
<el-form-item label="送货时间" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点取货时间' : '取货时间'" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点送达时间' : '送货时间'" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</div>
</FormSection>
@@ -11,6 +11,7 @@ import { executeUsTruckCommand } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import { availableTruckCommands, truckCommandHints, truckCommandLabels, type TruckCommand, type TruckOperation } from '../truckWorkflow'
import type { LocalTransportDetail } from '../types'
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses/api/platformWarehouses'
const props = defineProps<{ detail: LocalTransportDetail }>()
const emit = defineEmits<{ updated: [detail: LocalTransportDetail]; busy: [value: boolean] }>()
@@ -20,7 +21,8 @@ const open = ref(false), saving = ref(false)
const active = ref<TruckCommand | null>(null)
const error = ref<ApiErrorDetails | null>(null), validationError = ref('')
const pending = ref<TruckOperation | null>(null)
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' })
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' })
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const selectablePallets = computed(() => props.detail.pallets.filter(pallet => pallet.status === 'RECEIVED'))
const review = computed(() => active.value === 'review' || active.value === 'review-pallet-overage')
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'truck-order-command', label: computed(() => active.value ? truckCommandLabels[active.value] : '卡车单据操作'), isOpen: computed(() => open.value || !!pending.value), model: reactive({ form, pending }), requiredPermission: computed(() => overseasBusinessPermissions.truckStatus) })
@@ -30,25 +32,33 @@ watch(() => [props.detail.customerId, props.detail.orderNo, props.detail.version
pending.value = null; open.value = false; active.value = null; error.value = null
})
function start(command: TruckCommand) {
async function start(command: TruckCommand) {
if (saving.value || !commands.value.includes(command)) return
if (pending.value) {
active.value = pending.value.command; open.value = true
return
}
active.value = command; error.value = null; validationError.value = ''
Object.assign(form, { decision: 'APPROVE', actualPalletCount: Math.max(1, props.detail.receivedPalletCount), packageCount: undefined, palletIds: [], remark: '' })
Object.assign(form, { decision: 'APPROVE', warehouseId: '', actualPalletCount: Math.max(1, props.detail.receivedPalletCount), packageCount: undefined, palletIds: [], remark: '' })
if (command === 'review' || command === 'arrange') {
try { warehouses.value = (await listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' })).records } catch { warehouses.value = [] }
}
open.value = true; captureBaseline()
}
function buildOperation(): TruckOperation | null {
if (!active.value) return null
const base = { version: props.detail.version, idempotencyKey: globalThis.crypto?.randomUUID?.() || `truck-${Date.now()}-${Math.random().toString(36).slice(2)}`, remark: form.remark.trim() || undefined }
if ((active.value === 'review' && form.decision === 'APPROVE') || active.value === 'arrange') {
if (!form.warehouseId) { validationError.value = '请选择 A 海外仓'; return null }
}
if ((active.value === 'cancel' || (review.value && form.decision === 'REJECT')) && !base.remark) {
validationError.value = '请填写拒绝或取消原因'; return null
}
switch (active.value) {
case 'review': case 'review-pallet-overage': return { command: active.value, payload: { ...base, decision: form.decision } }
case 'review': return { command: active.value, payload: { ...base, decision: form.decision, ...(form.decision === 'APPROVE' ? { warehouseId: form.warehouseId } : {}) } }
case 'arrange': return { command: active.value, payload: { ...base, warehouseId: form.warehouseId } }
case 'review-pallet-overage': return { command: active.value, payload: { ...base, decision: form.decision } }
case 'receive-pallets':
if (!Number.isSafeInteger(form.actualPalletCount) || form.actualPalletCount < Math.max(1, props.detail.receivedPalletCount)) {
validationError.value = '累计实收托数须为正整数,且不能少于已实收托数'; return null
@@ -70,7 +80,9 @@ function buildOperation(): TruckOperation | null {
async function submit() {
if (saving.value || !active.value || !commands.value.includes(active.value)) return
validationError.value = ''; error.value = null
const operation = buildOperation()
// Keep an operation immutable after an uncertain response so a retry cannot
// accidentally use a different version, idempotency key, or edited payload.
const operation = pending.value || buildOperation()
if (!operation) return
pending.value = operation; saving.value = true
const customerId = props.detail.customerId, orderNo = props.detail.orderNo
@@ -82,7 +94,7 @@ async function submit() {
emit('updated', updated)
} catch (cause) {
if (props.detail.customerId === customerId && props.detail.orderNo === orderNo) error.value = getApiErrorDetails(cause, '操作未确认成功,请重试或刷新订单核对最新记录')
} finally { pending.value = null; saving.value = false }
} finally { saving.value = false }
}
</script>
@@ -98,6 +110,7 @@ async function submit() {
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
<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="选择 A 海外仓" 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>
<template v-if="active === 'receive-pallets'">
<el-form-item label="累计实收托数" required><el-input-number v-model="form.actualPalletCount" :min="Math.max(1, detail.receivedPalletCount)" :precision="0" /></el-form-item>
<el-form-item label="实收件数"><el-input-number v-model="form.packageCount" :min="0" :precision="0" /></el-form-item>
+5
View File
@@ -1,3 +1,8 @@
export { overseasBusinessPermissions } from './permissions'
export { overseasBusinessRoutes } from './routes'
export type * from './types'
export { listOverseasFeeItems } from './api/overseasFees'
export { feeStatus, sameProjectFees, sameProjectFeeSummary } from './overseasFeePresentation'
export type { OverseasFee } from './api/overseasFees'
export { default as BusinessDocumentFees } from './components/BusinessDocumentFees.vue'
@@ -0,0 +1,8 @@
.overseas-fee-descriptions .el-descriptions__table {
table-layout: fixed;
}
.overseas-fee-descriptions .el-descriptions__content.el-descriptions__cell {
overflow-wrap: anywhere;
white-space: normal;
}
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { feeActions, feePermissions, feeProcessingLabel, feeProcessingStatus, feeStatus, feeTime, sameProjectFees, sameProjectFeeSummary } from './overseasFeePresentation'
import type { OverseasFee } from './api/overseasFees'
const fee = (overrides: Partial<OverseasFee> = {}): OverseasFee => ({
direction: 'RECEIVABLE',
id: '9007199254740993', sourceFeeId: '9007199254740993', sourceVersion: 1, customerId: '1', customerName: '客户',
sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_TRANSFER', businessId: '2', businessNo: 'WT1',
orderId: null, warehouseId: '3', feeItemCode: 'FEE1', feeItemName: '操作费', businessDate: '2026-10-22',
currency: 'CNY', amount: 70, originalCurrency: 'USD', originalAmount: 10, chargeableQuantity: null, quantityUnit: null,
unitPrice: null, periodStart: null, periodEnd: null, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', publicationStatus: 'PENDING',
collectionMode: 'WALLET', walletStatus: 'FROZEN', walletErrorMessage: null, reviewReason: null, reviewedAt: null,
createdAt: '', updatedAt: '', allowedActions: [], ...overrides,
})
describe('海外费用动作资格', () => {
it('明确时区的同一时刻显示一致,旧无时区时间不猜测转换', () => {
expect(feeTime('2026-10-23T10:43:25Z')).toBe(feeTime('2026-10-23T18:43:25+08:00'))
const date = new Date('2026-10-23T10:43:25Z')
expect(feeTime('2026-10-23T10:43:25Z')).toContain(String(date.getHours()).padStart(2, '0') + ':43:25')
expect(feeTime('2026-10-23T10:43:25')).toBe('2026-10-23 10:43:25')
expect(feeTime(null)).toBe('—')
})
it('以服务端动作白名单为准,不根据待审核状态自行开放审核', () => {
expect(feeActions(fee(), () => true).map(row => row.key)).toEqual(['DETAIL'])
expect(feeActions(fee({ allowedActions: ['APPROVE', 'REJECT', 'CANCEL'] }), permission => permission === feePermissions.review).map(row => row.key)).toEqual(['DETAIL', 'REVIEW'])
expect(feeActions(fee({ allowedActions: ['APPROVE', 'CANCEL'] }), permission => permission === feePermissions.edit).map(row => row.key)).toEqual(['DETAIL'])
expect(feeActions(fee({ allowedActions: ['REJECT', 'CANCEL'] }), permission => permission === feePermissions.review).map(row => row.key)).toEqual(['DETAIL', 'REVIEW'])
})
it('不为未知动作暴露按钮,请求中全部动作禁用', () => {
const actions = feeActions(fee({ allowedActions: ['DELETE', 'RETRY_FREEZE'] }), () => true, true)
expect(actions.map(row => row.key)).toEqual(['DETAIL', 'RETRY_FREEZE'])
expect(actions.every(row => row.disabled)).toBe(true)
})
it.each([{ reviewStatus: 'REJECTED' }, { sourceStatus: 'VOIDED' }])('关闭费用仅保留服务端允许的资金释放恢复 %o', overrides => {
const closedFee = fee({ ...overrides, walletStatus: 'RELEASE_FAILED', allowedActions: ['APPROVE', 'ADJUST', 'RETRY_SETTLE', 'RETRY_FREEZE', 'RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'] })
expect(feeActions(closedFee, () => true).map(row => row.key)).toEqual(['DETAIL', 'RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'])
expect(feeActions(closedFee, () => false).map(row => row.key)).toEqual(['DETAIL'])
expect(feeProcessingLabel(closedFee)).toBe('处理失败')
})
it('区分账单推送与实际余额扣款', () => {
expect(feeStatus(fee({ walletStatus: 'SETTLE_PENDING' }))).toBe('扣费中')
expect(feeStatus(fee({ walletStatus: 'SETTLED' }))).toBe('已扣费')
expect(feeStatus(fee({ collectionMode: 'BILL', publicationStatus: 'PUBLISHED' }))).toBe('已推送结算')
expect(feeStatus(fee({ sourceStatus: 'CANCELLED' }))).toBe('已取消')
})
it.each([
[{ walletStatus: 'FROZEN' }, 'WAITING'], [{ walletStatus: 'FREEZE_PENDING' }, 'PROCESSING'],
[{ walletStatus: 'SETTLE_FAILED' }, 'FAILED'], [{ walletStatus: 'RELEASE_FAILED' }, 'FAILED'],
[{ walletStatus: 'SETTLED' }, 'COMPLETED'], [{ collectionMode: 'BILL', publicationStatus: 'PUBLISHED', deliveryStatus: 'PENDING' }, 'PROCESSING'],
[{ publicationStatus: 'PUBLISHED', deliveryStatus: 'DEAD' }, 'FAILED'], [{ publicationStatus: 'PUBLISHED', deliveryStatus: 'PUBLISHED' }, 'COMPLETED'],
[{ publicationStatus: 'PUBLISHED', walletStatus: 'SETTLED', deliveryStatus: 'PENDING', deliveryErrorMessage: 'S0500' }, 'COMPLETED'],
[{ publicationStatus: 'PUBLISHED', walletStatus: 'SETTLED', deliveryStatus: 'PROCESSING', deliveryErrorMessage: 'S0500' }, 'COMPLETED'],
[{ publicationStatus: 'PUBLISHED', deliveryStatus: 'PUBLISHED', deliveryErrorMessage: '旧错误' }, 'COMPLETED'],
[{ reviewStatus: 'REJECTED', walletStatus: 'RELEASE_PENDING' }, 'PROCESSING'], [{ sourceStatus: 'VOIDED' }, 'CLOSED'],
] as Array<[Partial<OverseasFee>, string]>)('业务状态归并 %o → %s', (overrides, status) => {
expect(feeProcessingStatus(fee(overrides))).toBe(status)
})
it('业务界面只显示完成或失败,审核前及短暂处理过程不显示额外状态', () => {
for (const walletStatus of ['FREEZE_PENDING', 'FROZEN', 'SETTLE_PENDING', 'RELEASE_PENDING', 'RELEASED']) {
expect(feeProcessingLabel(fee({ walletStatus }))).toBe('—')
}
expect(feeProcessingLabel(fee({ reviewStatus: 'REJECTED' }))).toBe('—')
expect(feeProcessingLabel(fee({ walletStatus: 'SETTLE_FAILED' }))).toBe('处理失败')
expect(feeProcessingLabel(fee({ walletStatus: 'SETTLED' }))).toBe('已完成')
})
it('应付成功只表示推送成功,失败或等待回执时不能显示已付款', () => {
expect(feeProcessingLabel(fee({ direction: 'PAYABLE', collectionMode: 'BILL', walletStatus: 'NOT_REQUIRED', publicationStatus: 'PUBLISHED', deliveryStatus: 'PUBLISHED' }))).toBe('推送成功')
expect(feeProcessingLabel(fee({ direction: 'PAYABLE', collectionMode: 'BILL', publicationStatus: 'PUBLISHED', deliveryStatus: 'DEAD' }))).toBe('处理失败')
expect(feeProcessingLabel(fee({ direction: 'PAYABLE', collectionMode: 'BILL', publicationStatus: 'PUBLISHED', deliveryStatus: 'PENDING' }))).toBe('—')
})
it('重复提醒包含驳回记录且区分收付方向,金额按原币展示', () => {
const original = fee({ sourceFeeId: 'A', reviewStatus: 'REJECTED' })
const payable = fee({ sourceFeeId: 'B', direction: 'PAYABLE' })
const voided = fee({ sourceFeeId: 'C', sourceStatus: 'VOIDED' })
const differentItem = fee({ sourceFeeId: 'D', feeItemCode: 'OTHER' })
expect(sameProjectFees([original, payable, voided, differentItem], 'FEE1', 'RECEIVABLE')).toEqual([original])
expect(sameProjectFees([original], 'FEE1', 'RECEIVABLE', 'A')).toEqual([])
expect(sameProjectFeeSummary(original)).toBe('USD 10.00 · 已驳回')
})
})
@@ -0,0 +1,107 @@
import type { RowActionItem } from '@/shared/components/RowActions.vue'
import type { OverseasFee } from './api/overseasFees'
export const feePermissions = {
list: 'platform:overseas-fee:list', edit: 'platform:finance:fee-source:edit', review: 'platform:finance:fee-source:review',
} as const
export const businessLabels: Record<string, string> = {
LASTLEG_ORDER: '尾程订单', WAREHOUSE_TRANSFER: '仓储转运', RETURN_ORDER: '退货', RELABEL_ORDER: '换标',
US_TRUCK: '本土卡车', LOCAL_LOOSEBOARD: '本土散板', OVERSEAS_PROCUREMENT: '海外采购', DISCARD_ORDER: '废弃',
OVERSEAS_INBOUND: '入库', WAREHOUSE_SERVICE: '库内服务', DELIVERY_ORDER: '派送', OVERSEAS_CONTAINER: '海外柜次',
}
export const reviewLabels: Record<string, string> = { DRAFT: '草稿', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
/** 已终结来源优先显示终态,避免保留的审核快照误导用户继续审核。 */
export function feeReviewLabel(fee: Pick<OverseasFee, 'sourceStatus' | 'reviewStatus'>) {
if (fee.sourceStatus === 'VOIDED') return '已作废'
if (fee.sourceStatus === 'CANCELLED') return '已取消'
if (fee.sourceStatus === 'SUPERSEDED') return '已调整'
return reviewLabels[fee.reviewStatus] || fee.reviewStatus
}
/** 失败发生于冻结、实扣或释放的不同阶段,不能一律称为扣费失败。 */
export function feeFundingErrorLabel(fee: Pick<OverseasFee, 'walletStatus'>) {
if (fee.walletStatus === 'FREEZE_FAILED') return '冻结失败'
if (fee.walletStatus === 'RELEASE_FAILED') return '释放失败'
return '扣费异常'
}
/** 业务分类取服务端真实单据类型,派送费项目不改变换标单归属。 */
export function feeBusinessLabel(fee: Pick<OverseasFee, 'businessType' | 'displayBusinessType'>) {
const type = fee.displayBusinessType || fee.businessType
return businessLabels[type] || type
}
export const walletLabels: Record<string, string> = {
FREEZE_PENDING: '锁定中', FROZEN: '已锁定', FREEZE_FAILED: '锁定失败', SETTLE_PENDING: '扣费中', SETTLED: '已扣费',
SETTLE_FAILED: '扣费失败', RELEASE_PENDING: '释放中', RELEASED: '已释放', RELEASE_FAILED: '释放失败',
}
export const processingLabels: Record<string, string> = { COMPLETED: '已完成', FAILED: '处理失败' }
/** 余额实扣即完成;账单推送按接收回执判断,自动重试期间保留真实失败。 */
export function feeProcessingStatus(fee: Pick<OverseasFee, 'sourceStatus' | 'publicationStatus' | 'reviewStatus' | 'walletStatus' | 'deliveryStatus' | 'deliveryErrorMessage'>) {
// 业务关闭不代表冻结资金已释放;失败必须保留可见,方便恢复处理。
if (fee.walletStatus === 'RELEASE_FAILED' || (['FREEZE_PENDING', 'RELEASE_PENDING'].includes(fee.walletStatus || '')
&& (fee.deliveryStatus === 'DEAD' || !!fee.deliveryErrorMessage?.trim()))) return 'FAILED'
if (['FREEZE_PENDING', 'RELEASE_PENDING'].includes(fee.walletStatus || '')) return 'PROCESSING'
if (['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus) || fee.reviewStatus === 'REJECTED') return 'CLOSED'
if (fee.walletStatus === 'SETTLED') return 'COMPLETED'
if (fee.deliveryStatus === 'DEAD' || (['PENDING', 'PROCESSING'].includes(fee.deliveryStatus || '') && fee.deliveryErrorMessage?.trim()) || fee.walletStatus?.endsWith('_FAILED')) return 'FAILED'
if (fee.deliveryStatus === 'PENDING' || fee.deliveryStatus === 'PROCESSING' || fee.walletStatus?.endsWith('_PENDING')) return 'PROCESSING'
if (fee.publicationStatus === 'PUBLISHED') return 'COMPLETED'
return 'WAITING'
}
/** 应付完成仅代表财务已接收,不能用完成文案暗示供应商已收款。 */
export const feeProcessingLabel = (fee: OverseasFee) => feeProcessingStatus(fee) === 'COMPLETED' && fee.direction === 'PAYABLE'
? '推送成功' : processingLabels[feeProcessingStatus(fee)] || '—'
/** 只提醒当前有效版本的同项目,已驳回记录仍用于解释重新录费原因。 */
export function sameProjectFees(rows: OverseasFee[], code: string, direction: string, excludeId?: string) {
return rows.filter(row => row.feeItemCode === code && row.direction === direction && row.sourceStatus === 'ACTIVE' && row.sourceFeeId !== excludeId)
}
/** 重复收费提示同时交代原币金额和审核结果,不把不同币种直接相加。 */
export const sameProjectFeeSummary = (fee: OverseasFee) => `${feeMoney(fee.originalAmount, fee.originalCurrency)} · ${reviewLabels[fee.reviewStatus] || fee.reviewStatus}`
export const retryResultLabels: Record<string, string> = { SUCCESS: '重试成功', FAILED: '重试后仍失败', SUBMITTED: '已提交重试' }
export const actionLabels: Record<string, string> = {
REVIEW: '审核', APPROVE: '审核通过', REJECT: '驳回', RETRY_FREEZE: '重试锁定', RETRY_SETTLE: '重试扣费',
RETRY_RELEASE: '重试释放', SUBMIT: '提交审核', VOID: '作废', ADJUST: '调整',
RETRY_PUBLISH: '重试推送', RETRY_FUNDING_DELIVERY: '重试资金指令', ADJUST_RENT: '调整仓租',
}
export function feeStatus(fee: Pick<OverseasFee, 'sourceStatus' | 'publicationStatus' | 'deliveryStatus' | 'deliveryKind'> & { collectionMode?: string | null; walletStatus?: string | null }) {
if (fee.sourceStatus === 'VOIDED') return '已作废'
if (fee.sourceStatus === 'SUPERSEDED') return '已调整'
if (fee.sourceStatus === 'CANCELLED') return '已取消'
if (fee.collectionMode === 'WALLET' && fee.walletStatus === 'SETTLED') return '已扣费'
if (fee.deliveryKind === 'FUNDING' && fee.deliveryStatus === 'DEAD') return '资金指令发送失败'
if (fee.deliveryKind === 'FEE' && fee.deliveryStatus) {
if (fee.deliveryStatus === 'DEAD') return '推送失败'
if (fee.deliveryStatus !== 'PUBLISHED') return fee.collectionMode === 'WALLET' ? '已扣费,待推送' : '推送中'
}
if (fee.collectionMode === 'BILL') return fee.publicationStatus === 'PUBLISHED' ? '已推送结算' : '待推送结算'
return fee.walletStatus ? walletLabels[fee.walletStatus] || fee.walletStatus : fee.collectionMode === 'WALLET' ? '待锁定' : '—'
}
export function feeActions(fee: OverseasFee, can: (permission: string) => boolean, busy = false): RowActionItem[] {
const closed = fee.reviewStatus === 'REJECTED' || ['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus)
const canReview = can(feePermissions.review)
const visibleActions = fee.allowedActions.filter(action => action in actionLabels && !['APPROVE', 'REJECT', 'CANCEL'].includes(action))
.filter(action => !closed || ['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'].includes(action))
.filter(action => can(['VOID'].includes(action) ? feePermissions.review : feePermissions.edit))
.map(action => ({ key: action, label: actionLabels[action]!, priority: 20,
tone: ['VOID'].includes(action) ? 'danger' as const : 'primary' as const, disabled: busy }))
// 审核通过与驳回共用一个入口;取消不在业务单据费用行提供。
if (!closed && canReview && fee.allowedActions.some(action => ['APPROVE', 'REJECT'].includes(action))) {
visibleActions.push({ key: 'REVIEW', label: actionLabels.REVIEW!, priority: 90, tone: 'primary', disabled: busy })
}
return [{ key: 'DETAIL', label: '详情', priority: 100, disabled: busy }, ...visibleActions]
}
export function feeMoney(amount: number | string | null | undefined, currency = '') {
if (amount == null) return '—'
return `${currency} ${Number(amount).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`.trim()
}
export function feeTime(value?: string | null) {
if (!value) return '—'
// 只有服务端明确标记时区的时间才转换,兼容旧的无时区数据。
if (/(?:Z|[+-]\d{2}:\d{2})$/i.test(value)) {
const date = new Date(value)
if (!Number.isNaN(date.getTime())) {
const pad = (part: number) => String(part).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
}
return value.replace('T', ' ').slice(0, 19)
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import type { OverseasFeeRetryRecord } from './api/overseasFees'
import { retryFailureReason } from './overseasFeeRetryPresentation'
const failed = (errorMessage: string | null, action = '重试推送'): OverseasFeeRetryRecord => ({
requestId: 'retry-1', requestedAt: '', operatorName: '操作员', action, result: 'FAILED', errorMessage,
})
describe('费用重试失败原因', () => {
it('指出财务接收失败,但不把通用内部错误猜成字段故障', () => {
const text = retryFailureReason(failed('EventDeliveryFailure: Consumer rejected event: code=S0500, message=Internal server error, requestId=abc, traceId=def'))
expect(text).toBe('推送结算失败:财务系统处理异常,请联系管理员排查后重试。')
expect(text).not.toMatch(/字段|EventDeliveryFailure|requestId|traceId/)
})
it('只有明确的字段错误才说明字段长度不足', () => {
expect(retryFailureReason(failed("Data truncation: Data too long for column 'payload_hash' at row 1")))
.toBe('推送结算失败:财务系统的摘要字段长度不足,请联系管理员更新数据库后重试。')
})
it('扣费与余额不足明确指出客户账户及处理方式', () => {
expect(retryFailureReason(failed('可用额度不足', '重试扣费')))
.toBe('扣费失败:客户余额及可用授信不足,请充值或调整授信后重试。')
})
it('超时不能当作未扣款,提示先核查实际费用状态', () => {
expect(retryFailureReason(failed('Read timed out', '重试资金指令')))
.toBe('发送资金指令失败:未及时收到财务处理结果,请先核对费用状态再重试。')
})
it.each([
['Connection refused', '无法连接财务系统'],
["Table 'baiyue_finance.fin_inbox_event' doesn't exist", '缺少必要的表或字段'],
[null, '未返回具体原因'],
['UnknownFailure: unrecognized', '根据技术详情排查'],
['Consumer rejected event: code=FIN-EVT-017, message=费用来源与成功扣款的客户、业务或金额不匹配, requestId=abc', '费用来源与成功扣款的客户、业务或金额不匹配'],
])('显示已知原因,未知异常保留排查入口:%s', (raw, expected) => {
expect(retryFailureReason(failed(raw))).toContain(expected)
})
it('成功及已提交记录不展示过期失败原因', () => {
for (const result of ['SUCCESS', 'SUBMITTED'] as const) {
expect(retryFailureReason({ ...failed('旧失败原因'), result })).toBe('—')
}
})
})
@@ -0,0 +1,34 @@
import type { OverseasFeeRetryRecord } from './api/overseasFees'
/** 将已知失败转换成业务说明,保留原始异常供技术详情查看,不从通用错误猜测具体故障。 */
export function retryFailureReason(row: OverseasFeeRetryRecord): string {
if (row.result !== 'FAILED') return '—'
const step = ({ 重试推送: '推送结算', 重试扣费: '扣费', 重试锁定: '锁定费用', 重试释放: '释放费用', 重试资金指令: '发送资金指令' } as Record<string, string>)[row.action] || '费用处理'
const prefix = `${step}失败:`
const raw = row.errorMessage?.trim() || ''
if (!raw) return `${prefix}系统未返回具体原因,请联系管理员核查。`
if (/余额不足|额度不足|授信不足|insufficient (?:funds|balance|credit)/i.test(raw)) {
return `${prefix}客户余额及可用授信不足,请充值或调整授信后重试。`
}
if (/Data too long for column ['"`](?:payload_hash|source_hash)['"`]/i.test(raw)) {
return `${prefix}财务系统的摘要字段长度不足,请联系管理员更新数据库后重试。`
}
if (/doesn't exist|unknown column|缺少.*(?:表|字段)/i.test(raw)) {
return `${prefix}系统缺少必要的表或字段,请联系管理员更新数据库后重试。`
}
if (/timeout|timed out|超时/i.test(raw)) {
return `${prefix}未及时收到财务处理结果,请先核对费用状态再重试。`
}
if (/connection refused|connectexception|connection reset|连接失败|连接被拒绝|no available (?:server|instance)/i.test(raw)) {
return `${prefix}无法连接财务系统,请联系管理员检查服务后重试。`
}
if (/code=S0500|internal server error/i.test(raw)) {
return `${prefix}财务系统处理异常,请联系管理员排查后重试。`
}
// 保留服务端返回的中文业务原因,隐藏异常类名、请求编号和堆栈。
const message = raw.match(/\bmessage=(.*?)(?:,\s*(?:requestId|traceId)=|$)/i)?.[1]?.trim() || raw
if (/[\u4e00-\u9fff]/.test(message) && !/Exception|\bat\s+[\w.]+\(|\b(?:SELECT|INSERT|UPDATE|DELETE)\b|requestId=|traceId=/i.test(message)) {
return prefix + message
}
return `${prefix}系统处理异常,请联系管理员根据技术详情排查。`
}
@@ -0,0 +1,76 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { ApiResponseError } from '@/shared/api/http'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness'
import type { LocalLooseboardDetail } from '../types'
import LooseboardOrderDetailView from './LooseboardOrderDetailView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('../api/platformOverseasBusiness', () => ({ getLocalLooseboardOrder: vi.fn() }))
function order(overrides: Partial<LocalLooseboardDetail> = {}): LocalLooseboardDetail {
return {
id: '9007199254741001', orderNo: 'LB-TEST', customerId: '9007199254741999', fleetArrangement: null,
palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', productName: '测试货物', estimatedPalletCount: 2,
origin: 'LA', originAddress: '起运仓地址', destination: 'ONT', destinationAddress: '目的仓地址',
originContactName: '发货人', originContactPhone: '10001', destinationContactName: '收货人', destinationContactPhone: '10002',
pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', packageCount: 20,
totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
status: 'PENDING_ACCEPTANCE', version: 1, remark: '运输备注', createdBy: '7',
createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00', histories: [], ...overrides,
}
}
function mountDetail() {
return mount(LooseboardOrderDetailView, { global: { plugins: [ElementPlus], stubs: { BusinessDocumentFees: true, FleetArrangementSummary: true, DataTable: true } } })
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.clearAllMocks()
routing.route = reactive({ params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
vi.mocked(getLocalLooseboardOrder).mockResolvedValue(order())
})
describe('looseboard document fees', () => {
it('loads the customer scoped document and passes its exact identity to fees', async () => {
const wrapper = mountDetail(); await flushPromises()
expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-TEST', '9007199254741999')
expect(wrapper.findComponent(BusinessDocumentFees).props()).toMatchObject({
businessType: 'LOCAL_LOOSEBOARD', businessNo: 'LB-TEST', customerId: '9007199254741999', businessId: '9007199254741001',
})
expect(wrapper.text()).toContain('起运仓地址')
expect(wrapper.findAll('.status-badge')).toHaveLength(1)
})
it('does not expose fees or query a document without a real customer', async () => {
routing.route.query = {}
const wrapper = mountDetail(); await flushPromises()
expect(getLocalLooseboardOrder).not.toHaveBeenCalled()
expect(wrapper.findComponent(BusinessDocumentFees).exists()).toBe(false)
expect(wrapper.text()).toContain('缺少有效客户或订单信息')
})
it('keeps error metadata and retries before exposing fee operations', async () => {
vi.mocked(getLocalLooseboardOrder).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0409', 409, undefined, 'LB-409')).mockResolvedValueOnce(order())
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.text()).toContain('LB-409')
expect(wrapper.findComponent(BusinessDocumentFees).exists()).toBe(false)
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
expect(wrapper.findComponent(BusinessDocumentFees).exists()).toBe(true)
})
it('discards an older response when navigating to another order', async () => {
let completeFirst!: (value: LocalLooseboardDetail) => void
vi.mocked(getLocalLooseboardOrder).mockImplementationOnce(() => new Promise(resolve => { completeFirst = resolve }))
.mockResolvedValueOnce(order({ id: '9007199254741002', orderNo: 'LB-NEXT' }))
const wrapper = mountDetail()
routing.route.params.orderId = 'LB-NEXT'; await flushPromises()
completeFirst(order()); await flushPromises()
expect(wrapper.findComponent(BusinessDocumentFees).props('businessId')).toBe('9007199254741002')
expect(wrapper.findComponent(BusinessDocumentFees).props('businessNo')).toBe('LB-NEXT')
})
})
@@ -0,0 +1,87 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import { poolOrderStatusLabels } from '../fleetArrangement'
import type { LocalLooseboardDetail, OverseasBusinessHistory } from '../types'
const route = useRoute(), router = useRouter()
const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const detail = ref<LocalLooseboardDetail | null>(null), loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
let requestSequence = 0
const dateTime = (value: string | null | undefined) => value ? value.replace('T', ' ').slice(0, 16) : '—'
const statusLabels: Record<string, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', ...poolOrderStatusLabels }
const statusLabel = (value: string | null) => value ? statusLabels[value] || value : '—'
const fields = computed(() => detail.value ? [
['客户编号', detail.value.customerId], ['商品名称', detail.value.productName], ['预计托数', detail.value.estimatedPalletCount],
['起运地', detail.value.origin], ['起运地详细地址', detail.value.originAddress], ['起运地联系人', detail.value.originContactName], ['起运地联系电话', detail.value.originContactPhone],
['目的地', detail.value.destination], ['目的地详细地址', detail.value.destinationAddress], ['目的地联系人', detail.value.destinationContactName], ['目的地联系电话', detail.value.destinationContactPhone],
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
['件数', detail.value.packageCount], ['总重量(kg)', detail.value.totalWeightKg], ['总体积(m³)', detail.value.totalVolumeCbm],
['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`],
['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark],
] : [])
const operationLabels: Record<string, string> = { CREATE: '创建', UPDATE: '编辑', REVIEW: '订单审核', STATUS_CHANGE: '状态变更', CANCEL: '取消订单' }
const historyColumns: DataTableColumn[] = [
{ key: 'createdAt', label: '操作时间', minWidth: 170, formatter: (row: OverseasBusinessHistory) => dateTime(row.createdAt) },
{ key: 'operationType', label: '操作', minWidth: 130, formatter: (row: OverseasBusinessHistory) => operationLabels[row.operationType || ''] || row.operationType || '—' },
{ key: 'fromStatus', label: '原状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.fromStatus) },
{ key: 'toStatus', label: '新状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.toStatus) },
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 140 },
{ key: 'remark', prop: 'remark', label: '操作说明', minWidth: 240 },
]
/** 只用路由中的真实订单和客户查询,切换单据时丢弃旧请求结果。 */
async function load() {
const sequence = ++requestSequence
detail.value = null; error.value = null
if (!orderNo.value || !customerId.value || customerId.value === '0') {
loading.value = false; error.value = { message: '缺少有效客户或订单信息,请返回本土散板列表重新打开' }; return
}
loading.value = true
try { const result = await getLocalLooseboardOrder(orderNo.value, customerId.value); if (sequence === requestSequence) detail.value = result }
catch (cause) { if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '本土散板订单加载失败,请稍后重试') }
finally { if (sequence === requestSequence) loading.value = false }
}
watch([orderNo, customerId], load, { immediate: true })
</script>
<template>
<AdminPage class="admin-density admin-workbench"><DetailPageLayout aria-label="本土散板订单详情" class="density-detail-layout workbench-panel is-flush is-page-surface">
<template #header>
<div class="density-detail-header looseboard-detail-header"><div class="density-detail-heading">
<el-button link class="density-back" @click="router.push({ name: 'overseas-business-looseboards' })"><ArrowLeft :size="16" />返回列表</el-button>
<h1 class="density-detail-title">本土散板订单 {{ detail?.orderNo || orderNo }}</h1>
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
</div><el-button v-if="detail" @click="load">刷新订单</el-button></div>
</template>
<LoadErrorState v-if="error" title="本土散板订单暂时无法加载" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-skeleton v-else-if="loading" :rows="8" animated />
<template v-else-if="detail">
<section v-if="detail.fleetArrangement" class="looseboard-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
<section class="looseboard-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
<section class="looseboard-detail-section"><BusinessDocumentFees business-type="LOCAL_LOOSEBOARD" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
<section class="looseboard-detail-section"><h2>操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
</template>
</DetailPageLayout></AdminPage>
</template>
<style scoped>
.looseboard-detail-header { flex-wrap: wrap; }
.looseboard-detail-header .density-detail-heading { flex: 1; }
.looseboard-detail-section { margin-bottom: var(--admin-space-6); }
</style>
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants } from '@/modules/iam'
import type { PlatformProcurement } from '../procurementTypes'
import { downloadProcurementFile } from '../api/procurementCommands'
import { getProcurementWorkflow } from '../api/procurementWorkflow'
import type { FleetArrangement } from '../fleetArrangement'
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder } from '../types'
import {
@@ -30,6 +31,7 @@ const purchaseDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/ov
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/procurementWorkflow', () => ({ getProcurementWorkflow: vi.fn(), submitProcurementWorkflow: vi.fn() }))
vi.mock('../api/procurementCommands', async importOriginal => ({
...await importOriginal<typeof import('../api/procurementCommands')>(),
downloadProcurementFile: vi.fn(),
@@ -46,7 +48,7 @@ function memoryStorage(): Storage { const values = new Map<string, string>(); re
const purchase = {
id: '9007199254740993', orderNo: 'OP-100', customerId: '10001', customerName: '客户甲', purchaseCategory: '家居', productName: '置物架', productSpecification: '黑色', productUrl: 'https://example.test/product',
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', status: 'WAIT_PACKING' as const, version: 3,
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', status: 'RECEIVING' as const, version: 3,
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086',
estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
histories: [{ operationType: 'UPDATE', fromStatus: 'WAIT_PURCHASE', toStatus: 'WAIT_PURCHASE', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [],
@@ -70,6 +72,7 @@ describe('overseas business mounted pages', () => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = ''
routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
vi.mocked(downloadProcurementFile).mockReset()
vi.mocked(getProcurementWorkflow).mockReset().mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] })
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(procurementDetail()); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 })
vi.mocked(getOverseasPurchaseEditor).mockReset().mockResolvedValue({ customerView: { order: { orderNo: purchase.orderNo, customerId: purchase.customerId, productName: purchase.productName, purchaseQuantity: purchase.purchaseQuantity, version: purchase.version } }, internalRemark: purchase.remark })
vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }])
@@ -161,12 +164,13 @@ describe('overseas business mounted pages', () => {
const wrapper = mountPage(PurchaseOrderDetailView)
await flushPromises()
expect(wrapper.get('.density-detail-title').text()).toBe('OP-100')
expect(wrapper.get('[aria-label="采购订单概览"]').text()).toContain('洛杉矶采购仓')
expect(wrapper.findAll('.density-fields')).toHaveLength(3)
expect(wrapper.text()).toContain('洛杉矶采购仓')
expect(wrapper.findAll('.density-fields')).toHaveLength(2)
expect(wrapper.text()).toContain('预算单价')
expect(wrapper.text()).toContain('两段实际交寄')
expect(wrapper.text()).not.toContain('库存信息')
expect(wrapper.findAll('.density-fields .wide')).toHaveLength(2)
expect(wrapper.text()).toContain('收货记录')
expect(wrapper.text()).toContain('出库单')
expect(wrapper.text()).toContain('采购库存')
expect(wrapper.text()).not.toContain('两段实际交寄')
expect(wrapper.text()).not.toContain('internal-purchase-route')
await wrapper.get('.density-back').trigger('click')
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchases' })
@@ -177,12 +181,13 @@ describe('overseas business mounted pages', () => {
const result = procurementDetail()
result.files = [{ id: '9007199254741000', fileId: '9007199254741001', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 123, createdAt: '2026-10-10T10:00:00' }]
vi.mocked(getOverseasPurchase).mockResolvedValue(result)
vi.mocked(getProcurementWorkflow).mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [{ id: '1', quantity: 5, fileIds: ['9007199254741001'], receivedAt: '2026-10-10T10:00:00' }], outbounds: [] })
let rejectDownload!: (error: Error) => void
vi.mocked(downloadProcurementFile).mockImplementation(() => new Promise((_resolve, reject) => { rejectDownload = reject }))
const wrapper = mountPage(PurchaseOrderDetailView)
useAuthStore().permissions = ['platform:overseas-business:purchase:list']
await flushPromises()
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text() === '下载')
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text() === 'label.pdf')
expect(button).toBeDefined()
await button!.trigger('click'); await flushPromises()
expect(downloadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', '9007199254741001')
@@ -194,18 +199,14 @@ describe('overseas business mounted pages', () => {
expect(document.body.textContent).toContain('文件下载失败')
})
it('renders estimate holds separately from actual payment without claiming inbound settlement', async () => {
it('uses the shared overseas fees panel for the exact procurement customer and document', async () => {
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
const result = procurementDetail()
result.customerView.fees = [{ feeNo: 'FEE-1', purpose: 'INTERNATIONAL_ESTIMATE', name: '国际运费预估', version: 1, active: true, determination: 'POSITIVE', amount: '25.00', currency: 'CNY', originalAmount: '25.00', originalCurrency: 'CNY', exchangeRate: '1', exchangeRateMonth: '2026-10', businessDate: '2026-10-09', parentFeeNo: null, walletStatus: 'FROZEN', sourceLinked: false }]
vi.mocked(getOverseasPurchase).mockResolvedValue(result)
const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises()
expect(wrapper.text()).toContain('已冻结,未扣款')
expect(wrapper.text()).toContain('预估不入池')
expect(wrapper.text()).not.toContain('订单全部入库后结算')
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).props()).toEqual(expect.objectContaining({ businessType: 'OVERSEAS_PROCUREMENT', businessNo: 'OP-100', customerId: '10001', businessId: '9007199254740993' }))
expect(wrapper.text()).not.toContain('预估不入池')
})
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待装箱'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('收货中'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it('keeps long procurement identifiers and descriptive fields out of single-line truncation', async () => {
const record = { ...purchase, orderNo: 'QLYSXDKH202610100002', customerName: '全量验收下单客户-20261010-长客户名称', productName: 'PROC-PURCHASE-20261010-长商品名称', productSpecification: '多规格描述-完整展示', purchaseCategory: 'OFFICE_SUPPLIES', purchaseWarehouseName: '洛杉矶海外采购收货仓库' }
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [record], page: 1, size: 20, total: 1, pages: 1 })
@@ -223,7 +224,7 @@ describe('overseas business mounted pages', () => {
it('refreshes the cached procurement detail after an edit completes', () => { expect(purchaseDetailSource).toContain('onActivated'); expect(purchaseDetailSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) })
it('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') })
it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') })
it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('收货记录'); expect(wrapper.text()).toContain('出库单'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') })
it('submits only the internal remark and preserves input after a version conflict', async () => {
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
@@ -276,10 +277,10 @@ describe('overseas business mounted pages', () => {
expect(model.totalWeightKg).toBe(0); expect(model.totalVolumeCbm).toBe(0)
expect(model.budgetAmount).toBeUndefined(); expect(model.currency).toBe('USD')
const labels = wrapper.findAll('.el-form-item__label').map(item => item.text())
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual([
'客户', ...(domain === 'truck' ? ['车辆类型'] : []), '起运地', '目的地',
'起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址',
])
const routeLabels = domain === 'truck'
? ['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', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: 0, currency: 'CNY' })
const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')!
await submit.trigger('click'); await flushPromises()
@@ -307,7 +308,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 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 ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) 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 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 () => {
@@ -0,0 +1,215 @@
import ElementPlus from 'element-plus'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import * as api from '../api/overseasFees'
import type { OverseasFee } from '../api/overseasFees'
import OverseasFeesView from './OverseasFeesView.vue'
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/overseasFees', () => ({
actOnOverseasFee: vi.fn(), getOverseasFee: vi.fn(), listOverseasFeeCustomers: vi.fn(), listOverseasFeeItems: vi.fn(),
listOverseasFeeRetries: vi.fn(),
listOverseasFees: vi.fn(), listBusinessFeeOptions: vi.fn(), createOverseasFee: vi.fn(),
}))
const fee: OverseasFee = {
direction: 'RECEIVABLE',
id: '9223372036854775001', sourceFeeId: '9223372036854775001', sourceVersion: 3,
customerId: '9223372036854775002', customerName: '测试客户', sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_TRANSFER',
businessId: '9223372036854775003', businessNo: 'WT202610220001', orderId: null, warehouseId: '9223372036854775004',
feeItemCode: 'HANDLING', feeItemName: '操作费', businessDate: '2026-10-22', currency: 'CNY', amount: 70,
originalCurrency: 'USD', originalAmount: 10, chargeableQuantity: null, quantityUnit: null, unitPrice: null,
periodStart: null, periodEnd: null, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', publicationStatus: 'UNPUBLISHED',
collectionMode: 'WALLET', walletStatus: 'FROZEN', walletErrorMessage: null, reviewReason: null, reviewedAt: null,
createdAt: '2026-10-22T10:00:00', updatedAt: '', allowedActions: ['APPROVE', 'REJECT', 'CANCEL'],
}
async function mountPage() {
const wrapper = mount(OverseasFeesView, { attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await flushPromises()
return wrapper
}
async function click(wrapper: VueWrapper, label: string, selector = 'button') {
const button = wrapper.findAll(selector).find(node => node.text().trim() === label)
expect(button, label).toBeDefined()
await button!.trigger('click'); await flushPromises()
}
function activeDialog(wrapper: VueWrapper) {
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('modelValue'))
expect(dialog).toBeDefined()
return dialog!.getComponent({ name: 'ElDialogContent' })
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.mocked(api.listOverseasFeeRetries).mockResolvedValue([])
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(api.listOverseasFeeCustomers).mockResolvedValue([{ customerId: fee.customerId, customerName: fee.customerName }])
vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'HANDLING', feeTypeName: '操作费', defaultPrice: 20 }])
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [fee], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(fee)
vi.mocked(api.actOnOverseasFee).mockResolvedValue(fee)
vi.mocked(api.createOverseasFee).mockResolvedValue(fee)
vi.mocked(api.listBusinessFeeOptions).mockResolvedValue([{ businessId: fee.businessId!, businessType: fee.businessType, businessNo: fee.businessNo,
customerId: fee.customerId, customerName: fee.customerName, warehouseId: fee.warehouseId, orderId: null }])
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('海外费用汇总与失败恢复', () => {
it('推送请求提交后继续刷新,真实成功时列表自动显示已完成', async () => {
const pushing = { ...fee, reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED',
walletStatus: 'NOT_REQUIRED', deliveryStatus: 'PENDING' as const, allowedActions: [] }
const wrapper = await mountPage()
try {
// 挂载后再使用假时钟,避免影响Element Plus自身挂载任务。
vi.useFakeTimers()
vi.mocked(api.listOverseasFees).mockResolvedValueOnce({ records: [pushing], total: 1, page: 1, size: 20, pages: 1 })
.mockResolvedValue({ records: [{ ...pushing, deliveryStatus: 'PUBLISHED' }], total: 1, page: 1, size: 20, pages: 1 })
wrapper.getComponent(QueryToolbar).vm.$emit('search', {})
await flushPromises()
expect(wrapper.get('.el-table__body-wrapper').text()).not.toContain('已完成')
await vi.advanceTimersByTimeAsync(2000); await flushPromises()
expect(wrapper.get('.el-table__body-wrapper').text()).toContain('已完成')
const calls = vi.mocked(api.listOverseasFees).mock.calls.length
await vi.advanceTimersByTimeAsync(4000)
expect(api.listOverseasFees).toHaveBeenCalledTimes(calls)
} finally { wrapper.unmount(); vi.useRealTimers() }
})
it('shows relabel delivery as 换标 in table and detail and filters by that business type', async () => {
const relabel = { ...fee, businessType: 'LASTLEG_ORDER', displayBusinessType: 'RELABEL_ORDER',
businessNo: 'TB202610220001', feeItemName: '派送费', originalCurrency: 'CNY', originalAmount: 66.70, amount: 66.70 }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [relabel], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(relabel)
const wrapper = await mountPage()
const table = wrapper.get('.el-table__body-wrapper')
expect(table.text()).toContain('换标'); expect(table.text()).toContain('派送费')
expect(table.text()).toContain('66.70'); expect(table.text()).not.toContain('尾程订单')
wrapper.getComponent(QueryToolbar).vm.$emit('search', { businessType: 'RELABEL_ORDER' })
await flushPromises()
expect(api.listOverseasFees).toHaveBeenLastCalledWith(expect.objectContaining({ businessType: 'RELABEL_ORDER', page: 1 }))
await click(wrapper, '详情', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain('换标'); expect(dialog.text()).toContain('派送费')
expect(dialog.text()).not.toContain('尾程订单')
})
it('keeps approval and amount changes on the business document', async () => {
const allActions = { ...fee, allowedActions: ['APPROVE', 'REJECT', 'CANCEL', 'ADJUST', 'VOID', 'SUBMIT'] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [allActions], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(allActions)
const wrapper = await mountPage()
wrapper.getComponent(QueryToolbar).vm.$emit('search', { customerId: fee.customerId, businessNo: ' WT2026 ', reviewStatus: 'PENDING' })
await flushPromises()
expect(api.listOverseasFees).toHaveBeenLastCalledWith(expect.objectContaining({ customerId: fee.customerId, businessNo: 'WT2026', reviewStatus: 'PENDING', page: 1 }))
await click(wrapper, '详情', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain(fee.businessNo)
expect(dialog.text()).toContain('余额/授信')
const buttons = wrapper.findAll('button').map(button => button.text().trim())
for (const label of ['新增费用', '调整', '审核通过', '驳回', '取消费用', '作废', '提交审核', '重试锁定']) expect(buttons).not.toContain(label)
expect(api.createOverseasFee).not.toHaveBeenCalled()
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
it.each(['RETRY_FREEZE', 'RETRY_SETTLE', 'RETRY_RELEASE', 'RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'])('retries %s only after refreshing eligibility and preserving the original identity', async action => {
const failed = { ...fee, reviewStatus: 'APPROVED', walletStatus: 'SETTLE_FAILED', deliveryEventId: 'original-event', allowedActions: [action] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(failed)
const labels: Record<string, string> = { RETRY_FREEZE: '重试锁定', RETRY_SETTLE: '重试扣费', RETRY_RELEASE: '重试释放', RETRY_PUBLISH: '重试推送', RETRY_FUNDING_DELIVERY: '重试资金指令' }
const wrapper = await mountPage()
await click(wrapper, labels[action]!, '.el-table__body-wrapper button')
expect(api.getOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId)
await click(activeDialog(wrapper), labels[action]!)
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, action, {
idempotencyKey: expect.any(String), ...(['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action) ? { expectedVersion: fee.sourceVersion, eventId: 'original-event' } : {}),
})
expect(api.listOverseasFees).toHaveBeenCalledTimes(2)
})
it('does not retry a fee that has already succeeded or belongs to another customer', async () => {
const failed = { ...fee, allowedActions: ['RETRY_SETTLE'] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 })
const wrapper = await mountPage()
for (const refreshed of [{ ...failed, allowedActions: [] }, { ...failed, customerId: 'other' }]) {
vi.mocked(api.getOverseasFee).mockResolvedValueOnce(refreshed)
await click(wrapper, '重试扣费', '.el-table__body-wrapper button')
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('modelValue'))).toBe(false)
}
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
it('blocks concurrent clicks and reuses the same request key after an uncertain failure', async () => {
const failed = { ...fee, allowedActions: ['RETRY_SETTLE'] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(failed)
let reject!: (reason: unknown) => void
vi.mocked(api.actOnOverseasFee).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const wrapper = await mountPage()
await click(wrapper, '重试扣费', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
await click(dialog, '重试扣费'); await click(dialog, '重试扣费')
expect(api.actOnOverseasFee).toHaveBeenCalledTimes(1)
reject(new ApiResponseError('连接中断', 'UNAVAILABLE', 503)); await flushPromises()
expect(dialog.text()).toContain('连接中断')
const key = vi.mocked(api.actOnOverseasFee).mock.calls[0]![2].idempotencyKey
await click(dialog, '重试扣费')
expect(vi.mocked(api.actOnOverseasFee).mock.calls[1]![2].idempotencyKey).toBe(key)
})
it('hides retries without edit permission and keeps rejected fees read-only', async () => {
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['RETRY_SETTLE'] }, { ...fee, sourceFeeId: 'rejected', reviewStatus: 'REJECTED', allowedActions: ['RETRY_SETTLE'] }], total: 2, page: 1, size: 20, pages: 1 })
can.mockReturnValue(false)
const wrapper = await mountPage()
expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text())).not.toContain('重试扣费')
})
it.each(['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'])('recovers rejected frozen funds through the server-authorized %s action', async action => {
const rejected = { ...fee, reviewStatus: 'REJECTED', walletStatus: 'RELEASE_FAILED', deliveryEventId: 'release-event', allowedActions: [action, 'RETRY_SETTLE', 'RETRY_FREEZE'] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [rejected], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(rejected)
const wrapper = await mountPage()
const buttons = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(buttons).not.toContain('重试扣费'); expect(buttons).not.toContain('重试锁定')
expect(wrapper.get('.el-table__body-wrapper').text()).toContain('处理失败')
const label = action === 'RETRY_RELEASE' ? '重试释放' : '重试资金指令'
await click(wrapper, label, '.el-table__body-wrapper button')
await click(activeDialog(wrapper), label)
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, action, expect.objectContaining({
idempotencyKey: expect.any(String), ...(action === 'RETRY_FUNDING_DELIVERY' ? { expectedVersion: fee.sourceVersion, eventId: 'release-event' } : {}),
}))
})
it('keeps automatic rent available as a summary filter', async () => {
vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'FEE_TYPE_077', feeTypeName: '仓储费', defaultPrice: null }])
const wrapper = await mountPage()
expect(wrapper.getComponent(QueryToolbar).props('fields').find(field => field.key === 'feeItemCode')?.options).toContainEqual({ value: 'FEE_TYPE_077', label: '仓储费' })
})
it('shows only final results and queries failure filtering on the server for either direction', async () => {
const wrapper = await mountPage()
const fields = wrapper.getComponent(QueryToolbar).props('fields')
expect(fields.find(field => field.key === 'processingStatus')?.options?.map(option => option.label)).toEqual(['已完成', '处理失败'])
expect(fields.some(field => field.key === 'walletStatus')).toBe(false)
wrapper.getComponent(QueryToolbar).vm.$emit('search', { direction: 'PAYABLE', processingStatus: 'FAILED' })
await flushPromises()
expect(api.listOverseasFees).toHaveBeenLastCalledWith(expect.objectContaining({ direction: 'PAYABLE', processingStatus: 'FAILED', page: 1 }))
expect(wrapper.get('.el-table__body-wrapper').text()).toContain('—')
expect(wrapper.get('.el-table__body-wrapper').text()).not.toContain('已锁定')
})
})
it('海外费用管理只查询仓租依据,审核和调价留在对应单据详情', async () => {
const rent: OverseasFee = { ...fee, feeItemCode: 'FEE_TYPE_077', feeItemName: '仓储费', allowedActions: ['APPROVE', 'REJECT', 'ADJUST_RENT'],
storageRent: { outboundNo: 'OUT-1', outboundAt: '2026-10-22T10:00:00Z', calculatedAmount: 10, chargedAmount: null,
lines: [{ lotId: '1', inboundAt: '2026-10-01T10:00:00Z', quantity: 4, days: 21, calculatedAmount: 10 }] } }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [rent], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(rent)
const wrapper = await mountPage()
expect(wrapper.findAll('[role="tab"]').map(item => item.text())).toEqual(['费用明细', '仓租库存'])
const actions = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(actions).not.toContain('调整仓租')
expect(actions).not.toContain('审核仓租')
await click(wrapper, '详情', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain('2026-10-01'); expect(dialog.text()).toContain('2026-10-22')
expect(dialog.text()).toContain('实扣金额')
expect(dialog.text()).toContain('OUT-1')
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
@@ -0,0 +1,186 @@
<script setup lang="ts">
import '../overseasFeeDetails.css'
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions from '@/shared/components/RowActions.vue'
import type { FeeItemOption } from '../components/OverseasFeeEditor.vue'
import StorageRentFeeDetails from '../components/StorageRentFeeDetails.vue'
import StorageRentPanel from '../components/StorageRentPanel.vue'
import OverseasFeeRetryHistory from '../components/OverseasFeeRetryHistory.vue'
import { useFeeStatusRefresh } from '../useFeeStatusRefresh'
import { actOnOverseasFee, getOverseasFee, listOverseasFeeCustomers, listOverseasFeeItems, listOverseasFees, type OverseasFee } from '../api/overseasFees'
import { actionLabels, businessLabels, feeBusinessLabel, feeMoney, feePermissions, feeProcessingLabel, feeTime, feeReviewLabel, feeFundingErrorLabel, processingLabels, reviewLabels } from '../overseasFeePresentation'
const tab = ref('fees'), loading = ref(false), error = ref<ApiErrorDetails | null>(null), contextError = ref<ApiErrorDetails | null>(null)
const page = ref(1), pageSize = ref(20), total = ref(0), rows = ref<OverseasFee[]>([])
const customers = ref<Array<{ customerId: string; customerName: string }>>([]), items = ref<FeeItemOption[]>([])
const contextLoading = ref(false), detailLoading = ref(''), detail = ref<OverseasFee | null>(null), detailOpen = ref(false)
const filters = ref<QueryModel>({ customerId: '', businessNo: '', businessType: '', feeItemCode: '', reviewStatus: '', processingStatus: '', direction: 'RECEIVABLE' })
const applied = ref({ ...filters.value })
const access = useAccessPreviewStore()
const retryFee = ref<OverseasFee | null>(null), retryOpen = ref(false), retryAction = ref(''), retryKey = ref(''), retrySaving = ref(false), retryError = ref<ApiErrorDetails | null>(null)
const retryActions = ['RETRY_FREEZE', 'RETRY_SETTLE', 'RETRY_RELEASE', 'RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY']
function rowActions(fee: OverseasFee) {
const disabled = !!detailLoading.value || retrySaving.value
const actions = [{ key: 'DETAIL', label: '详情', disabled }]
const closed = fee.reviewStatus === 'REJECTED' || ['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus)
if (access.can(feePermissions.edit)) for (const action of retryActions) {
if (closed && !['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'].includes(action)) continue
if (fee.allowedActions.includes(action)) actions.push({ key: action, label: actionLabels[action]!, disabled })
}
return actions
}
const fields = computed<QueryField[]>(() => [
{ key: 'direction', type: 'select', label: '收付方向', placeholder: '收付方向', options: [{ value: 'RECEIVABLE', label: '应收客户' }, { value: 'PAYABLE', label: '应付供应商' }], props: { 'aria-label': '收付方向', clearable: false } },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '客户', width: 200, options: customers.value.map(row => ({ value: row.customerId, label: row.customerName })), props: { filterable: true, loading: contextLoading.value, 'aria-label': '客户' } },
{ key: 'businessNo', type: 'input', label: '业务单号', placeholder: '业务单号', width: 220, props: { 'aria-label': '业务单号' } },
{ key: 'businessType', type: 'select', label: '业务类型', placeholder: '业务类型', options: Object.entries(businessLabels).map(([value, label]) => ({ value, label })), props: { 'aria-label': '业务类型' } },
{ key: 'feeItemCode', type: 'select', label: '费用项目', placeholder: '费用项目', options: items.value.map(row => ({ value: row.feeCode, label: row.feeTypeName })), props: { filterable: true, 'aria-label': '费用项目' } },
{ key: 'reviewStatus', type: 'select', label: '审核状态', placeholder: '审核状态', options: Object.entries(reviewLabels).map(([value, label]) => ({ value, label })), props: { 'aria-label': '审核状态' } },
{ key: 'processingStatus', type: 'select', label: '费用状态', placeholder: '费用状态', options: Object.entries(processingLabels).map(([value, label]) => ({ value, label: value === 'COMPLETED' && filters.value.direction === 'PAYABLE' ? '推送成功' : label })), props: { 'aria-label': '费用状态' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'businessNo', label: '业务单号', minWidth: 185 },
{ prop: 'businessType', label: '业务类型', width: 115, formatter: feeBusinessLabel },
{ prop: 'feeItemName', label: '费用项目', minWidth: 145 },
{ prop: 'supplierName', label: '供应商', minWidth: 150, defaultVisible: false },
{ key: 'amount', label: '结算金额(CNY)', width: 145, align: 'right', formatter: (row: OverseasFee) => feeMoney(row.amount) },
{ prop: 'collectionMode', label: '结算方式', width: 115, formatter: (row: OverseasFee) => row.direction === 'PAYABLE' ? '推送结算' : row.collectionMode === 'BILL' ? '账单结算' : '余额/授信' },
{ prop: 'reviewStatus', label: '审核状态', width: 100, slotName: 'reviewStatus' },
{ key: 'chargeStatus', label: '费用状态', minWidth: 115, formatter: feeProcessingLabel },
{ prop: 'businessDate', label: '费用日期', width: 110 },
{ prop: 'createdAt', label: '创建时间', minWidth: 165, defaultVisible: false, formatter: (row: OverseasFee) => feeTime(row.createdAt) },
]
let loadSequence = 0, detailSequence = 0
const statusRefresh = useFeeStatusRefresh(rows, () => load(true), computed(() => tab.value !== 'rent' && !error.value))
async function load(background = false) {
if (!background) statusRefresh.reset()
const current = ++loadSequence; if (!background) loading.value = true; error.value = null
try {
const params = Object.fromEntries(Object.entries(applied.value).map(([key, value]) => [key, value ? String(value).trim() : undefined]))
const data = await listOverseasFees({ ...params, page: page.value, size: pageSize.value, direction: params.direction || 'RECEIVABLE' })
if (current === loadSequence) {
rows.value = data.records; total.value = data.total
const latestDetail = rows.value.find(row => row.sourceFeeId === detail.value?.sourceFeeId)
if (latestDetail) detail.value = latestDetail
}
} catch (failure) { if (current === loadSequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(failure, '费用列表加载失败') } }
finally { if (current === loadSequence) loading.value = false }
}
async function loadContext() {
contextLoading.value = true; contextError.value = null
const results = await Promise.allSettled([listOverseasFeeCustomers(), listOverseasFeeItems()])
if (results[0].status === 'fulfilled') customers.value = results[0].value
else contextError.value = getApiErrorDetails(results[0].reason, '客户选项加载失败')
if (results[1].status === 'fulfilled') items.value = results[1].value
else contextError.value = getApiErrorDetails(results[1].reason, '费用项目加载失败')
contextLoading.value = false
}
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
async function freshFee(row: OverseasFee) {
const current = ++detailSequence; detailLoading.value = row.sourceFeeId
try { const data = await getOverseasFee(row.sourceFeeId); return current === detailSequence ? data : null }
catch (failure) { ElMessage.error(getApiErrorDetails(failure, '费用详情加载失败').message); return null }
finally { if (current === detailSequence) detailLoading.value = '' }
}
async function showDetail(row: OverseasFee) {
if (detailLoading.value) return
const current = await freshFee(row)
if (current) { detail.value = current; detailOpen.value = true }
}
async function onAction(action: string, row: OverseasFee) {
if (detailLoading.value || retrySaving.value) return
if (action === 'DETAIL') { await showDetail(row); return }
if (!retryActions.includes(action) || !rowActions(row).some(item => item.key === action)) return
const current = await freshFee(row)
if (!current) return
if (current.sourceFeeId !== row.sourceFeeId || current.customerId !== row.customerId || !rowActions(current).some(item => item.key === action)) {
ElMessage.warning('费用状态已变化,请刷新后重试'); await load(); return
}
retryFee.value = current; retryAction.value = action; retryKey.value = crypto.randomUUID(); retryError.value = null; retryOpen.value = true
}
async function confirmRetry() {
const fee = retryFee.value, action = retryAction.value
if (!fee || retrySaving.value || !rowActions(fee).some(item => item.key === action) || !access.can(feePermissions.edit)) return
const delivery = ['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action)
if (delivery && !fee.deliveryEventId) return
retrySaving.value = true; retryError.value = null
try {
await actOnOverseasFee(fee.sourceFeeId, action, { idempotencyKey: retryKey.value,
...(delivery ? { expectedVersion: fee.sourceVersion, eventId: fee.deliveryEventId } : {}),
})
retryOpen.value = false; ElMessage.success('重试已提交'); await load()
} catch (failure) { retryError.value = getApiErrorDetails(failure, '重试失败') }
finally { retrySaving.value = false }
}
// 返回费用页签时更新审核与资金结果,保留用户原来的查询条件。
watch(tab, () => { if (tab.value === 'fees') void load() })
watch([page, pageSize], () => { if (tab.value !== 'rent') void load() })
onMounted(() => { void load(); void loadContext() })
</script>
<template>
<AdminPage class="admin-density admin-workbench">
<ListPageLayout aria-label="海外费用明细">
<section class="workbench-table is-page-surface">
<el-tabs v-model="tab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
<el-tab-pane label="费用明细" name="fees">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" appearance="integrated" toolbar-placement="filters" preference-key="overseas-fees" row-key="sourceFeeId" empty-text="暂无费用明细">
<template #filters><QueryToolbar v-model="filters" ariaLabel="海外费用筛选" :fields="fields" :loading="loading" @search="search" /></template>
<template #notices>
<LoadErrorState v-if="error" title="费用列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="contextError" title="筛选选项加载失败" :message="contextError.message" :loading="contextLoading" @retry="loadContext"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState>
</template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as OverseasFee)" @action="onAction($event, row as OverseasFee)" /></template>
<template #reviewStatus="{ row }"><el-tag :type="(row as OverseasFee).reviewStatus === 'APPROVED' ? 'success' : (row as OverseasFee).reviewStatus === 'REJECTED' ? 'danger' : 'info'">{{ feeReviewLabel(row as OverseasFee) }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条费用</template>
</DataTable>
</el-tab-pane>
<el-tab-pane label="仓租库存" name="rent" lazy><StorageRentPanel :customers="customers" /></el-tab-pane>
</el-tabs>
</section>
</ListPageLayout>
<AppDialog v-model="detailOpen" title="费用详情" width="860px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" cancel-text="关闭" fullscreenable>
<template v-if="detail">
<el-descriptions :column="2" border class="density-descriptions overseas-fee-descriptions">
<el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="业务单号">{{ detail.businessNo }}</el-descriptions-item>
<el-descriptions-item label="业务类型">{{ feeBusinessLabel(detail) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item>
<el-descriptions-item v-if="detail.feeRemark" label="费用说明" :span="2">{{ detail.feeRemark }}</el-descriptions-item>
<el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '—' }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ feeMoney(detail.originalAmount, detail.originalCurrency) }}</el-descriptions-item><el-descriptions-item label="结算方式">{{ detail.direction === 'PAYABLE' ? '推送结算' : detail.collectionMode === 'BILL' ? '账单结算' : '余额/授信' }}</el-descriptions-item>
<el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算金额">{{ feeMoney(detail.amount, detail.currency) }}</el-descriptions-item><el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算汇率">{{ detail.exchangeRate || '—' }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item>
<el-descriptions-item label="审核状态">{{ feeReviewLabel(detail) }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ feeProcessingLabel(detail) }}</el-descriptions-item>
<el-descriptions-item label="费用日期">{{ detail.businessDate }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ feeTime(detail.createdAt) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.periodStart || detail.periodEnd" label="计费期间" :span="2">{{ detail.periodStart }} — {{ detail.periodEnd }}</el-descriptions-item>
<el-descriptions-item v-if="detail.reviewReason" label="审核意见" :span="2">{{ detail.reviewReason }}</el-descriptions-item>
<el-descriptions-item v-if="detail.walletErrorMessage" :label="feeFundingErrorLabel(detail)" :span="2">{{ detail.walletErrorMessage }}<span v-if="detail.sourceStatus === 'VOIDED'">(此费用已作废,无需继续审核)</span></el-descriptions-item>
<el-descriptions-item v-if="detail.deliveryErrorMessage" label="推送异常" :span="2">{{ detail.deliveryErrorMessage }}</el-descriptions-item>
</el-descriptions>
<StorageRentFeeDetails :fee="detail" />
<OverseasFeeRetryHistory v-if="detailOpen" :fee-id="detail.sourceFeeId" :refresh-key="[detail.deliveryStatus, detail.deliveryErrorMessage, detail.walletStatus].join('|')" />
</template>
</AppDialog>
<AppDialog v-model="retryOpen" :title="actionLabels[retryAction] || '重试'" width="560px" class="admin-density-dialog admin-workbench is-dialog" :confirm-text="actionLabels[retryAction] || '重试'" :confirm-loading="retrySaving" lock-while-submitting @confirm="confirmRetry">
<el-descriptions v-if="retryFee" :column="1" border class="density-descriptions">
<el-descriptions-item label="业务单号">{{ retryFee.businessNo }}</el-descriptions-item>
<el-descriptions-item label="费用项目">{{ retryFee.feeItemName }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ feeMoney(retryFee.amount, retryFee.currency) }}</el-descriptions-item>
<el-descriptions-item label="当前状态">{{ feeProcessingLabel(retryFee) }}</el-descriptions-item>
</el-descriptions>
<LoadErrorState v-if="retryError" title="重试失败" :message="retryError.message" :loading="retrySaving" @retry="confirmRetry"><template #details><ApiErrorMeta :details="retryError" /></template></LoadErrorState>
</AppDialog>
</AdminPage>
</template>
@@ -1,6 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, onActivated, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -9,7 +8,6 @@ import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -17,133 +15,60 @@ import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getOverseasPurchase } from '../api/platformOverseasBusiness'
import { downloadProcurementFile } from '../api/procurementCommands'
import ProcurementNodeActions from '../components/ProcurementNodeActions.vue'
import { remainingLabels, decisionLabels } from '../procurementControlForm'
import { getProcurementWorkflow, type ProcurementWorkflow } from '../api/procurementWorkflow'
import ProcurementWorkflowPanel from '../components/ProcurementWorkflowPanel.vue'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { overseasBusinessPermissions } from '../permissions'
import { statusLabel, money, dateTime, text, measurement, feeStatus, feePurposes } from '../procurementPresentation'
import type { PlatformProcurement, ProcurementFile, ProcurementFee, ProcurementReceipt, ProcurementCarton, ProcurementShipment } from '../procurementTypes'
import { statusLabel, money, dateTime, text } from '../procurementPresentation'
import type { PlatformProcurement } from '../procurementTypes'
import { directionLabel } from '../procurementDirection'
import { requireReviewedProcurementFees } from '../procurementOutboundFees'
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const access = useAccessPreviewStore()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const detail = ref<PlatformProcurement | null>(null); const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const operationError = ref<ApiErrorDetails | null>(null)
const downloading = ref<string | null>(null)
const detail = ref<PlatformProcurement | null>(null), workflow = ref<ProcurementWorkflow | null>(null), loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null), downloading = ref('')
const order = computed(() => detail.value?.customerView.order)
const feePanel = ref<InstanceType<typeof BusinessDocumentFees>>()
async function beforeOutbound() {
if (!feePanel.value) throw new Error('费用明细尚未加载完成,请稍后重试')
requireReviewedProcurementFees(await feePanel.value.getFees())
}
const fields = computed(() => [
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['商品规格', order.value?.productSpecification], ['采购数量', order.value?.purchaseQuantity],
['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['有效预算总额', money(detail.value?.customerView.budget.totalAmount, detail.value?.customerView.budget.currency)],
['预计到海外仓', dateTime(order.value?.estimatedArrivalAt)], ['实际收货仓', order.value?.actualWarehouseName],
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['下单收货地址', order.value?.receivingAddress], ['内部备注', detail.value?.internalRemark],
] as const)
const purchaseFields = computed(() => [
['实际购买时间', dateTime(detail.value?.purchaseFacts.purchased_at)], ['实购商品', detail.value?.purchaseFacts.purchased_product_name],
['实购数量', detail.value?.purchaseFacts.purchased_quantity], ['原登记实付总额', money(detail.value?.purchaseFacts.purchase_amount, detail.value?.purchaseFacts.purchase_currency)],
['货款核定时间', dateTime(detail.value?.purchaseFacts.purchase_confirmed_at)], ['待处理事项', detail.value?.purchaseFacts.purchase_issue],
] as const)
const receiptColumns: DataTableColumn[] = [
{ key: 'receiptNo', prop: 'receiptNo', label: '实收编号', minWidth: 180 },
{ key: 'leg', label: '收货段', width: 100, formatter: (row: ProcurementReceipt) => row.leg === 'OVERSEAS' ? '海外' : '国内' },
{ key: 'source', label: '来源箱', minWidth: 160, formatter: (row: ProcurementReceipt) => detail.value?.physical.cartons.find(box => box.id === row.sourceCartonId)?.cartonNo || '—' },
{ key: 'goodQuantity', prop: 'goodQuantity', label: '合格件数', width: 100 }, { key: 'damagedQuantity', prop: 'damagedQuantity', label: '破损件数', width: 100 }, { key: 'extraQuantity', prop: 'extraQuantity', label: '多货件数', width: 100 },
{ key: 'receivedAt', label: '实收时间', minWidth: 160, formatter: (row: ProcurementReceipt) => dateTime(row.receivedAt) },
{ key: 'active', label: '记录状态', width: 100, formatter: (row: ProcurementReceipt) => row.active ? '有效' : '已更正' },
]
const dispositionColumns: DataTableColumn[] = [
{ key: 'leg', label: '发生段', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => row.leg === 'OVERSEAS' ? '海外' : '国内' },
{ key: 'source', label: '实收或原箱来源', minWidth: 180, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => (row.receipt_id ? detail.value?.physical.receipts.find(receipt => receipt.id === row.receipt_id)?.receiptNo : row.source_carton_id ? detail.value?.physical.cartons.find(box => box.id === row.source_carton_id)?.cartonNo : order.value?.orderNo) || '—' },
{ key: 'kind', label: '差异', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => ({ SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货' })[row.kind] },
{ key: 'quantity', prop: 'quantity', label: '处置件数', width: 100 }, { key: 'reason', prop: 'reason', label: '处置依据', minWidth: 220 },
{ key: 'active', label: '状态', width: 110, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => row.active ? '有效' : '已撤销或更正' },
]
const cartonColumns: DataTableColumn[] = [
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 160 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 },
{ key: 'measurements', label: '长 × 宽 × 高(cm)', minWidth: 220, formatter: (row: ProcurementCarton) => [row.lengthCm, row.widthCm, row.heightCm].map(value => measurement(value, 2)).join(' × ') },
{ key: 'weight', label: '毛重(kg)', width: 120, formatter: (row: ProcurementCarton) => measurement(row.grossWeightKg, 3) },
{ key: 'sources', label: '装货来源', minWidth: 240, formatter: (row: ProcurementCarton) => row.sources.map(source => `${detail.value?.physical.receipts.find(receipt => receipt.id === source.receiptId)?.receiptNo || '未关联实收'}:${source.quantity} 件`).join(';') },
]
const shipmentColumns: DataTableColumn[] = [
{ key: 'shipmentNo', prop: 'shipmentNo', label: '交寄编号', minWidth: 180 }, { key: 'leg', label: '运输段', width: 110, formatter: (row: ProcurementShipment) => row.leg === 'OVERSEAS' ? '国际交寄' : '寄客户' },
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 150 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 100 }, { key: 'trackingNo', prop: 'trackingNo', label: '实际运单', minWidth: 180 },
{ key: 'shippedAt', label: '交寄时间', minWidth: 160, formatter: (row: ProcurementShipment) => dateTime(row.shippedAt) },
]
const feeColumns: DataTableColumn[] = [
{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'purpose', label: '用途', minWidth: 140, formatter: (row: ProcurementFee) => feePurposes[row.purpose] || '待核对' },
{ key: 'name', prop: 'name', label: '费用项目', minWidth: 120 }, { key: 'original', label: '原币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.originalAmount, row.originalCurrency) },
{ key: 'amount', label: '人民币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.amount, row.currency) },
{ key: 'rate', label: '汇率 / 月份', minWidth: 150, formatter: (row: ProcurementFee) => `${text(row.exchangeRate)} / ${text(row.exchangeRateMonth)}` },
{ key: 'walletStatus', label: '资金状态', minWidth: 150, formatter: (row: ProcurementFee) => feeStatus(row) },
{ key: 'sourceLinked', label: '费用来源关联', width: 130, formatter: (row: ProcurementFee) => row.purpose === 'INTERNATIONAL_ESTIMATE' ? '预估不入池' : row.purpose === 'REFUND' ? '退款不入应收' : ['ZERO', 'NOT_APPLICABLE'].includes(row.determination) ? '无需入池' : row.sourceLinked ? '已完成' : '待完成' },
{ key: 'active', label: '版本状态', width: 100, formatter: (row: ProcurementFee) => row.active ? '有效' : '历史版本' },
]
const labelGroups = computed(() => detail.value ? [
{ leg: 'OVERSEAS', title: '国际段', value: detail.value.transport.overseasLabels },
{ leg: 'DOMESTIC', title: '寄客户段', value: detail.value.transport.domesticLabels },
].filter(group => group.value) : [])
const labelColumns: DataTableColumn[] = [
{ key: 'cartonId', label: '原箱', minWidth: 150, formatter: (row: { cartonId: string }) => detail.value?.physical.cartons.find(box => box.id === row.cartonId)?.cartonNo || '未关联箱' },
{ key: 'fileId', label: '面单文件', minWidth: 200, formatter: (row: { fileId: string }) => detail.value?.files.find(file => file.fileId === row.fileId)?.fileName || '未关联文件' },
{ key: 'page', prop: 'page', label: '页码', width: 75 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 120 }, { key: 'trackingNo', prop: 'trackingNo', label: '运单号', minWidth: 180 },
{ key: 'appliedAt', label: '实际贴单时间', minWidth: 160, formatter: (row: { appliedAt: string | null }) => dateTime(row.appliedAt) },
]
const fileLabels: Record<string, string> = { PURCHASE: '购买凭证', BUDGET: '预算授权', LABEL: '快递面单', EXCEPTION: '异常证据', RECEIPT: '收货凭证', VOUCHER: '费用凭证' }
const noteColumns: DataTableColumn[] = [
{ key: 'note_no', prop: 'note_no', label: '说明编号', minWidth: 180 },
{ key: 'target', label: '关联事实', minWidth: 180, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.target_type === 'PURCHASE' ? order.value?.orderNo || '—' : row.target_type === 'WAREHOUSE' ? row.original_value_json?.name || '实际仓记录' : row.target_type === 'RECEIPT' ? detail.value?.physical.receipts.find(item => item.id === row.target_id)?.receiptNo || '未关联实收' : detail.value?.transport.shipments.find(item => item.id === row.target_id)?.shipmentNo || '未关联交寄' },
{ key: 'reason', prop: 'reason', label: '内部事实说明', minWidth: 260 },
{ key: 'warehouseCorrection', label: '仓库更正留痕', minWidth: 190, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.corrected_value_json ? `${row.original_value_json?.name || '—'} → ${row.corrected_value_json.warehouseName || '—'}` : '—' },
{ key: 'block', label: '阻断状态', width: 140, formatter: (row: PlatformProcurement['controls']['notes'][number]) => !row.blocking ? '仅说明' : row.resolved_at ? '已解除阻断' : '待处理阻断' },
{ key: 'resolution_reason', prop: 'resolution_reason', label: '解除依据', minWidth: 220 },
{ key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 220 },
]
const remainingColumns: DataTableColumn[] = [{ key: 'businessNo', prop: 'businessNo', label: '货物来源', minWidth: 180 }, { key: 'kind', label: '剩余类型', minWidth: 130, formatter: (row: PlatformProcurement['controls']['remaining'][number]) => remainingLabels[row.kind] }, { key: 'quantity', prop: 'quantity', label: '待处理件数', width: 130 }]
const closureColumns: DataTableColumn[] = [{ key: 'closure_no', prop: 'closure_no', label: '关闭编号', minWidth: 180 }, { key: 'financial_conclusion', prop: 'financial_conclusion', label: '内部财务结论', minWidth: 250 }, { key: 'reason', prop: 'reason', label: '内部关闭原因', minWidth: 220 }, { key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 250 }, { key: 'created_at', label: '关闭时间', minWidth: 170, formatter: (row: PlatformProcurement['controls']['closures'][number]) => dateTime(row.created_at) }]
const closureItemColumns: DataTableColumn[] = [
{ key: 'source', label: '处置来源', minWidth: 180, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => row.source_kind === 'PURCHASE_REMAINDER' ? order.value?.orderNo || '—' : row.source_kind === 'TRANSIT_REMAINDER' ? detail.value?.physical.cartons.find(item => item.id === row.source_id)?.cartonNo || '未关联原箱' : detail.value?.physical.receipts.find(item => item.id === row.source_id)?.receiptNo || '未关联实收' },
{ key: 'source_kind', label: '来源类型', width: 130, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => remainingLabels[row.source_kind] },
{ key: 'quantity', prop: 'quantity', label: '件数', width: 90 }, { key: 'decision', label: '实际处置', minWidth: 150, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => decisionLabels[row.decision] || '待核对' }, { key: 'destination', prop: 'destination', label: '实际去向与接收方', minWidth: 260 },
]
const correctionColumns: DataTableColumn[] = [
{ key: 'correctionNo', prop: 'correctionNo', label: '更正编号', minWidth: 190 }, { key: 'rootFeeNo', prop: 'rootFeeNo', label: '原费用编号', minWidth: 170 },
{ key: 'targetOriginalAmount', label: '正确原币总额', minWidth: 150, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetOriginalAmount, row.originalCurrency) },
{ key: 'targetAmount', label: '目标人民币', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetAmount, 'CNY') },
{ key: 'previousNetAmount', label: '更正前净实扣', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.previousNetAmount, 'CNY') },
{ key: 'differenceAmount', label: '本次差额', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.differenceAmount, 'CNY') },
{ key: 'feeNos', label: '关联实际费用或调整', minWidth: 240, formatter: (row: PlatformProcurement['feeCorrections'][number]) => row.feeNos.join(';') || '无资金差额' },
]
const fileColumns: DataTableColumn[] = [
{ key: 'actions', slotName: 'actions', label: '操作', width: 90, fixed: 'left' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 250 },
{ key: 'category', label: '用途', width: 120, formatter: (row: ProcurementFile) => fileLabels[row.category] || '待核对' },
{ key: 'createdAt', label: '上传时间', minWidth: 160, formatter: (row: ProcurementFile) => dateTime(row.createdAt) },
]
const historyColumns: DataTableColumn[] = [{ key: 'createdAt', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['reviewHistory'][number]) => dateTime(row.createdAt) }, { key: 'operation', label: '操作', width: 120, formatter: (row: PlatformProcurement['reviewHistory'][number]) => ({ CREATE: '创建', REVIEW: '审核', UPDATE: '编辑备注' } as Record<string, string>)[row.operationType || ''] || '采购变更' }, { key: 'remark', prop: 'remark', label: '说明', minWidth: 250 }]
const commandColumns: DataTableColumn[] = [{ key: 'created_at', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['operations'][number]) => dateTime(row.created_at) }, { key: 'reason', prop: 'reason', label: '履约操作说明', minWidth: 400 }]
let generation = 0; let inFlight: Promise<void> | null = null; let inFlightScope = ''
['客户', order.value?.customerName], ['采购方向', directionLabel(order.value?.procurementDirection)], ['收货仓', order.value?.actualWarehouseName || order.value?.plannedWarehouseName],
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['规格', order.value?.productSpecification],
['采购数量', order.value?.purchaseQuantity], ['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['预计到仓', dateTime(order.value?.estimatedArrivalAt)],
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['收货地址', order.value?.receivingAddress],
...(detail.value?.internalRemark ? [['内部备注', detail.value.internalRemark]] : []),
])
let generation = 0, inFlight: Promise<void> | null = null, inFlightScope = ''
const scope = computed(() => `${auth.user?.id || ''}:${customerId.value}:${purchaseId.value}`)
onBeforeUnmount(() => { generation++ })
function load() {
const currentScope = scope.value
if (inFlight && inFlightScope === currentScope) return inFlight
const current = ++generation; const number = purchaseId.value; const customer = customerId.value
detail.value = null; loadError.value = null; loading.value = false
detail.value = null; workflow.value = null; loadError.value = null; loading.value = false
if (!number || !customer) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return Promise.resolve() }
loading.value = true; inFlightScope = currentScope
const promise = getOverseasPurchase(number, customer).then(result => { if (current === generation) detail.value = result }).catch(error => { if (current === generation) loadError.value = getApiErrorDetails(error, '采购详情加载失败') }).finally(() => {
const promise = Promise.all([getOverseasPurchase(number, customer), getProcurementWorkflow(number, customer)]).then(([result, process]) => {
if (current === generation) { detail.value = result; workflow.value = process }
}).catch(error => { if (current === generation) loadError.value = getApiErrorDetails(error, '采购详情加载失败') }).finally(() => {
if (current === generation) loading.value = false
if (inFlight === promise) inFlight = null
})
inFlight = promise; return promise
}
async function download(fileId: unknown) {
async function download(fileId: string) {
if (!order.value || downloading.value) return
const file = detail.value?.files.find(item => item.fileId === fileId)
if (!file || !order.value || downloading.value) return
downloading.value = file.fileId; operationError.value = null; const current = scope.value
downloading.value = fileId; operationError.value = null; const current = scope.value
try {
const blob = await downloadProcurementFile(order.value.orderNo, customerId.value, file.fileId)
const blob = await downloadProcurementFile(order.value.orderNo, customerId.value, fileId)
if (scope.value !== current) return
const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file?.fileName || fileId; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (error) { if (scope.value === current) operationError.value = getApiErrorDetails(error, '文件下载失败') }
finally { downloading.value = null }
finally { downloading.value = '' }
}
let activatedOnce = false
watch(scope, () => { void load() }, { immediate: true })
@@ -151,29 +76,14 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
</script>
<template>
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="operationError" />
<DetailPageLayout class="purchase-detail density-detail-layout workbench-panel is-flush is-page-surface" aria-label="采购订单履约详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="order" :status="statusLabel(order.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑内部备注</el-button></div></div></template>
<template v-if="order" #summary><dl class="density-detail-overview" aria-label="采购订单概览"><div><dt>客户</dt><dd>{{ text(order.customerName) }}</dd></div><div><dt>计划采购仓</dt><dd>{{ text(order.plannedWarehouseName) }}</dd></div><div><dt>采购数量</dt><dd>{{ order.purchaseQuantity }}</dd></div></dl></template>
<DetailPageLayout class="purchase-detail density-detail-layout workbench-panel is-flush is-page-surface" aria-label="采购订单详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="workflow" :status="statusLabel(workflow.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑备注</el-button></div></div></template>
<LoadErrorState v-if="loadError" title="采购详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="detail-grid">
<template v-if="detail">
<ProcurementNodeActions :detail="detail" :customer-id="customerId" @changed="load" />
<section><h3>订单及预算</h3><dl class="density-fields"><div v-for="field in fields" :key="field[0]" :class="{ wide: ['下单收货地址', '内部备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
<section><h3>购买事实</h3><dl class="density-fields"><div v-for="field in purchaseFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
<section><h3>履约进度</h3><dl class="density-fields"><div v-for="step in detail.customerView.progress" :key="step.key"><dt>{{ step.title }}</dt><dd>{{ step.completedAt ? dateTime(step.completedAt) : '未完成' }}</dd></div></dl></section>
<section><h3>分次实收记录</h3><div class="workbench-table"><DataTable :data="detail.physical.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无实收记录" /></div></section>
<section><h3>差异处置记录</h3><div class="workbench-table"><DataTable :data="detail.physical.dispositions" :columns="dispositionColumns" row-key="id" empty-text="暂无差异处置" /></div></section>
<section><h3>逐箱装货清单 · {{ detail.physical.packingStatus === 'CONFIRMED' ? '已确认' : detail.physical.packingStatus === 'DRAFT' ? '草稿' : '未建立' }}<span v-if="detail.physical.packingVersion">(第 {{ detail.physical.packingVersion }} 版)</span></h3><div class="workbench-table"><DataTable :data="detail.physical.cartons" :columns="cartonColumns" row-key="id" empty-text="暂无箱明细" /></div></section>
<section v-if="detail.transport.domesticCargo.length"><h3>国内包装与可寄货物核对</h3><div class="workbench-table"><DataTable :data="detail.transport.domesticCargo" :columns="cartonColumns" row-key="cartonId" /></div></section>
<section v-for="group in labelGroups" :key="group.leg"><h3>{{ group.title }}最近保存面单 · 第 {{ group.value?.version }} 版<InlineHelp title="面单核对说明"><p>包装或实收变化后须重新核对面单;是否可交寄以节点校验结果为准。</p></InlineHelp></h3><dl v-if="group.value" class="density-fields"><div class="wide"><dt>寄件信息</dt><dd>{{ group.value.sender.name }} · {{ group.value.sender.phone }} · {{ group.value.sender.address }}</dd></div><div class="wide"><dt>收件信息</dt><dd>{{ group.value.receiver.name }} · {{ group.value.receiver.phone }} · {{ group.value.receiver.address }}</dd></div></dl><div class="workbench-table"><DataTable :data="group.value?.labels || []" :columns="labelColumns" row-key="cartonId" /></div></section>
<section><h3>两段实际交寄</h3><div class="workbench-table"><DataTable :data="detail.customerView.shipments" :columns="shipmentColumns" empty-text="暂无实际交寄记录" /></div></section>
<section><h3>费用与资金结果<InlineHelp title="费用与资金说明"><p>样品货款与实际费用独立结算;预估国际运费仅冻结。资金结果和费用来源关联分别核对。</p></InlineHelp></h3><div class="workbench-table"><DataTable :data="detail.customerView.fees" :columns="feeColumns" row-key="feeNo" empty-text="暂无核定费用" /></div></section>
<section><h3>费用更正记录<InlineHelp title="费用更正说明"><p>本表记录核定目标、差额及未实扣费用的替代版本;实际扣款、退款结果见费用与资金明细。</p></InlineHelp></h3><div class="workbench-table"><DataTable :data="detail.feeCorrections" :columns="correctionColumns" row-key="correctionNo" empty-text="暂无费用目标更正" /></div></section>
<section><h3>事实说明与阻断</h3><div class="workbench-table"><DataTable :data="detail.controls.notes" :columns="noteColumns" row-key="id" empty-text="暂无事实说明或阻断" /></div></section>
<section><h3>剩余货物去向核对</h3><div class="workbench-table"><DataTable :data="detail.controls.remaining" :columns="remainingColumns" empty-text="当前无待处置的剩余货物" /></div></section>
<section v-if="detail.controls.closures.length"><h3>异常关闭记录</h3><div class="workbench-table"><DataTable :data="detail.controls.closures" :columns="closureColumns" row-key="id" /></div><div class="workbench-table"><DataTable :data="detail.controls.closureItems" :columns="closureItemColumns" row-key="id" empty-text="关闭时无剩余实物" /></div></section>
<section><h3>本单文件</h3><div class="workbench-table"><DataTable :data="detail.files" :columns="fileColumns" row-key="fileId" empty-text="暂无文件"><template #actions="{ row }"><el-button class="density-action" :loading="downloading === row.fileId" :disabled="!!downloading" @click="download(row.fileId)">下载</el-button></template></DataTable></div></section>
<section><h3>操作流水</h3><div class="workbench-table"><DataTable :data="detail.reviewHistory" :columns="historyColumns" empty-text="暂无基础操作记录" /></div><div class="workbench-table"><DataTable :data="detail.operations" :columns="commandColumns" empty-text="暂无履约操作记录" /></div></section>
<template v-if="detail && order && workflow">
<section><h3>采购信息</h3><dl class="density-fields"><div v-for="field in fields" :key="String(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
<ProcurementWorkflowPanel :detail="detail" :workflow="workflow" :customer-id="customerId" :downloading="downloading" :before-outbound="beforeOutbound" @changed="load" @download="download" />
<BusinessDocumentFees ref="feePanel" business-type="OVERSEAS_PROCUREMENT" :business-no="order.orderNo" :customer-id="customerId" :business-id="order.id" />
</template>
</div>
</DetailPageLayout>
@@ -183,6 +93,4 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
.detail-grid { display: grid; min-width: 0; gap: var(--admin-space-5); }
.detail-grid section { min-width: 0; }
.detail-grid h3 { margin: 0 0 var(--admin-space-3); font-size: var(--admin-font-section); }
.detail-grid p { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.density-fields > .wide { grid-column: span 2; }
</style>
@@ -17,6 +17,7 @@ import { listOverseasPurchaseCustomerOptions, listOverseasPurchases } from '../a
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseCustomerOption, OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
import { directionLabel } from '../procurementDirection'
const router = useRouter(); const access = useAccessPreviewStore(); const auth = useAuthStore()
const rows = ref<OverseasPurchaseSummary[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1); const pageSize = ref(20); const total = ref(0)
@@ -30,7 +31,7 @@ const fields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 120, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 220, singleLine: false },
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 220, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
@@ -41,6 +42,7 @@ const columns: DataTableColumn[] = [
{ key: 'purchaseQuantity', prop: 'purchaseQuantity', label: '采购数量', width: 100, align: 'right' },
{ key: 'status', label: '状态', minWidth: 145, slotName: 'status' },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: OverseasPurchaseSummary) => formatDateTime(row.createdAt) },
{ key: 'procurementDirection', label: '采购方向', minWidth: 170, formatter: (row: OverseasPurchaseSummary) => directionLabel(row.procurementDirection) },
]
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
let generation = 0; let inFlight: Promise<void> | null = null; let requestKey = ''
@@ -74,13 +76,13 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单">
<template #toolbar-start><h1>海外采购订单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="search" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as OverseasPurchaseSummary)">详情</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(overseasBusinessPermissions.purchaseEdit)" size="small" @click="open(row as OverseasPurchaseSummary, true)">编辑</el-button></div></template>
<template #status="{ row }"><el-tag :type="statusTone(row.status)" size="small" effect="light">{{ statusLabel(row.status) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="statusTone(row.status)" size="small" effect="light">{{ statusLabel(row.status, row.procurementDirection) }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条海外采购订单</template>
</DataTable></div></section>
</ListPageLayout></AdminPage>
@@ -0,0 +1,68 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import RowActions from '@/shared/components/RowActions.vue'
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions as permissions } from '../permissions'
import type { LocalLooseboardOrder } from '../types'
import TransportOrdersView from './TransportOrdersView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { query: {} } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('../api/platformOverseasBusiness', () => ({ listLocalLooseboardOrders: vi.fn(), listUsTruckOrders: vi.fn() }))
vi.mock('@/modules/overseas-master-data', () => ({ inquiryReturnTarget: () => null, TransportRouteDialog: { template: '<div />' }, routeLocation: (name: string) => ({ name }) }))
function order(): LocalLooseboardOrder {
return {
id: '9007199254741001', orderNo: 'LB-TEST', customerId: '9007199254741999', fleetArrangement: null,
palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', productName: '测试货物', estimatedPalletCount: 2,
origin: 'LA', destination: 'ONT', originContactName: '发货人', originContactPhone: '10001', destinationContactName: '收货人', destinationContactPhone: '10002',
pickupAt: null, deliveryAt: null, packageCount: 20, totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
status: 'PENDING_ACCEPTANCE', version: 1, createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00',
}
}
function mountList(domain: 'truck' | 'looseboard', edit = false) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '操作员', account: 'tester', roles: [] }
auth.permissions = [permissions.truckList, permissions.looseboardList, ...(edit ? [permissions.looseboardEdit] : [])]
useAccessPreviewStore().initializeAfterLogin()
return mount(TransportOrdersView, { props: { domain }, global: { plugins: [pinia, ElementPlus], stubs: {
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.id"><slot name="actions" :row="row" /></div></div>' },
AppDialog: true,
} } })
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(listLocalLooseboardOrders).mockResolvedValue({ records: [order()], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
})
describe('transport document entry', () => {
it('lets list-only users open looseboard details without edit permission', async () => {
const wrapper = mountList('looseboard'); await flushPromises()
const actions = wrapper.findComponent(RowActions)
expect(actions.text()).toContain('详情')
expect(actions.text()).not.toContain('编辑')
actions.vm.$emit('action', 'detail'); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-looseboard-detail', params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
})
it('preserves the permitted looseboard edit entry alongside its detail', async () => {
const wrapper = mountList('looseboard', true); await flushPromises()
const actions = wrapper.findComponent(RowActions)
expect(actions.text()).toContain('编辑')
actions.vm.$emit('action', 'edit'); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-looseboard-edit', params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
})
it('keeps truck mode on its own list API', async () => {
mountList('truck'); await flushPromises()
expect(listUsTruckOrders).toHaveBeenCalledOnce()
expect(listLocalLooseboardOrders).not.toHaveBeenCalled()
})
})
@@ -67,7 +67,7 @@ const truckColumns: DataTableColumn[] = [
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.createdAt) },
]
const looseboardColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 },
{ key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
@@ -106,6 +106,18 @@ function search(model: QueryModel) { applied.value = { ...model }; if (page.valu
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') }
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
/** 单据详情保留订单与客户范围,费用在原业务单中处理。 */
function openDetail(row: LocalTransportOrder | LocalLooseboardOrder) {
void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-detail' : 'overseas-business-truck-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } })
}
/** 散板详情不依赖编辑资格,已安排承运的订单也能处理自己的费用。 */
function looseboardActions(row: LocalLooseboardOrder): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 10 },
{ key: 'edit', label: '编辑', visible: !row.fleetArrangement && access.can(editPermission.value), disabled: !canEdit(row) },
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
]
}
function truckActions(row: LocalTransportOrder): RowActionItem[] {
return [
{ key: 'detail', label: row.status === 'PENDING_REVIEW' && !row.fleetArrangement && access.can(overseasBusinessPermissions.truckStatus) ? '审核' : '详情', priority: 10 },
@@ -113,10 +125,10 @@ function truckActions(row: LocalTransportOrder): RowActionItem[] {
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
]
}
function handleTruckAction(key: string, row: LocalTransportOrder) {
function handleTruckAction(key: string, row: LocalTransportOrder | LocalLooseboardOrder) {
if (key === 'edit') openEdit(row)
else if (key === 'arrangement') showArrangement(row)
else if (key === 'detail') void router.push({ name: 'overseas-business-truck-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } })
else if (key === 'detail') openDetail(row)
}
let activatedOnce = false
onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
@@ -134,7 +146,7 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
<template #actions="{ row }"><RowActions v-if="!isLooseboard" :actions="truckActions(row as LocalTransportOrder)" text-actions @action="handleTruckAction($event, row as LocalTransportOrder)" /><div v-else class="row-actions density-actions admin-text-actions"><el-button v-if="row.fleetArrangement" class="density-action" @click="showArrangement(row as LocalTransportOrder | LocalLooseboardOrder)">承运安排</el-button><el-tooltip v-if="!row.fleetArrangement && access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
<template #actions="{ row }"><RowActions :actions="isLooseboard ? looseboardActions(row as LocalLooseboardOrder) : truckActions(row as LocalTransportOrder)" text-actions @action="handleTruckAction($event, row as LocalTransportOrder | LocalLooseboardOrder)" /></template>
<template #status="{ row }"><el-tag :type="statusTones[row.status as LocalTransportStatus | LocalLooseboardStatus] || 'info'" size="small" effect="light">{{ statusLabels[row.status] || '—' }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
<template #routeMap="{ row }"><el-button v-if="row.orderNo" link type="primary" @click="openRoute(row as LocalLooseboardOrder)">查看路线</el-button></template>
@@ -7,6 +7,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import TruckOrderActions from '../components/TruckOrderActions.vue'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { executeUsTruckCommand, getUsTruckOrder } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions as permissions } from '../permissions'
import type { LocalTransportDetail } from '../types'
@@ -34,7 +35,7 @@ function mountDetail(manage = true) {
const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '操作员', account: 'tester', roles: [] }
auth.permissions = [permissions.truckList, ...(manage ? [permissions.truckStatus] : [])]
useAccessPreviewStore().initializeAfterLogin()
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true } } })
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true, BusinessDocumentFees: true, ElSelect: true, ElOption: true } } })
}
async function click(wrapper: VueWrapper, label: string) {
const button = wrapper.findAll('button').find(item => item.text() === label)
@@ -42,10 +43,14 @@ async function click(wrapper: VueWrapper, label: string) {
await button!.trigger('click'); await flushPromises()
}
async function confirm(wrapper: VueWrapper) {
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
const form = wrapper.findComponent(TruckOrderActions).findComponent({ name: 'ElForm' })
if (form.exists() && form.props('model').decision === 'APPROVE') form.props('model').warehouseId = '9'
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
}
const formModel = (wrapper: VueWrapper) => wrapper.findComponent(TruckOrderActions).findComponent({ name: 'ElForm' }).props('model')
vi.mock('@/modules/warehouses/api/platformWarehouses', () => ({ listPlatformWarehouses: vi.fn().mockResolvedValue({ records: [{ id: '9', warehouseCode: 'WH-9', warehouseName: '海外仓' }] }) }))
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.clearAllMocks()
@@ -56,6 +61,13 @@ beforeEach(() => {
afterEach(() => vi.unstubAllGlobals())
describe('truck review and fulfillment', () => {
it('scopes fee operations to the loaded truck document', async () => {
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.findComponent(BusinessDocumentFees).props()).toMatchObject({
businessType: 'US_TRUCK', businessNo: 'TR-TEST', customerId: '9007199254741999', businessId: '9007199254741001',
})
})
it('groups the business number, single status and actions in the shared detail header', async () => {
const wrapper = mountDetail(); await flushPromises()
const header = wrapper.find('.detail-page-layout__header')
@@ -70,21 +82,12 @@ describe('truck review and fulfillment', () => {
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-trucks' })
})
it('loads customer scoped documents and runs review through receipt, allocation and delivery completion', async () => {
it('loads customer scoped documents and moves approval directly to dispatch', async () => {
let current = order()
vi.mocked(executeUsTruckCommand).mockImplementation(async (_no, _customer, operation) => {
const next = { ...current, version: current.version + 1 }
switch (operation.command) {
case 'review': next.status = 'WAITING_INBOUND'; break
case 'receive-pallets':
next.status = 'WAITING_CONSOLIDATION'; next.palletCount = 2; next.receivedPalletCount = 2
next.pallets = [1, 2].map(sequenceNo => ({ id: `900719925474100${sequenceNo}`, sequenceNo, palletNo: `P-${sequenceNo}`, source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: '2026-10-20T10:00:00' }))
break
case 'allocate-pallets': next.status = 'ALLOCATED'; next.allocatedPalletCount = 2; next.pallets = next.pallets.map(row => ({ ...row, status: 'ALLOCATED' })); break
case 'prepare-dispatch': next.status = 'PENDING_DISPATCH'; break
case 'dispatch': next.status = 'DISPATCHED'; break
case 'start-transit': next.status = 'IN_TRANSIT'; break
case 'complete': next.status = 'COMPLETED'; break
case 'review': next.status = 'PENDING_DISPATCH'; break
}
next.histories = [...current.histories, { operationType: operation.command === 'review' ? 'REVIEW' : operation.command.toUpperCase().replaceAll('-', '_'), fromStatus: current.status, toStatus: next.status, operatorId: '操作员7', remark: operation.payload.remark || null, createdAt: '2026-10-20T10:00:00' }]
current = next; return next
@@ -92,79 +95,61 @@ describe('truck review and fulfillment', () => {
const wrapper = mountDetail(); await flushPromises()
expect(getUsTruckOrder).toHaveBeenCalledWith('TR-TEST', '9007199254741999')
expect(wrapper.text()).toContain('起运仓地址'); expect(wrapper.text()).toContain('审核与操作记录')
await click(wrapper, '审核'); formModel(wrapper).remark = '资料核对通过'; await confirm(wrapper)
expect(wrapper.text()).toContain('登记实收'); expect(wrapper.text()).toContain('资料核对通过')
await click(wrapper, '登记实收'); Object.assign(formModel(wrapper), { actualPalletCount: 2, packageCount: 20 }); await confirm(wrapper)
await click(wrapper, '分配托盘'); formModel(wrapper).palletIds = current.pallets.map(row => row.id); await confirm(wrapper)
for (const label of ['确认待派车', '确认已派车', '确认发运', '确认完成']) { await click(wrapper, label); await confirm(wrapper) }
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
expect(current.status).toBe('COMPLETED')
expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1, 2, 3, 4, 5, 6, 7])
expect(vi.mocked(executeUsTruckCommand).mock.calls[2][2]).toMatchObject({ command: 'allocate-pallets', payload: { palletIds: ['9007199254741001', '9007199254741002'] } })
await click(wrapper, '审核并生成排车'); formModel(wrapper).remark = '资料核对通过'; await confirm(wrapper)
expect(wrapper.text()).toContain('排车审核'); expect(wrapper.text()).toContain('资料核对通过')
expect(wrapper.text()).not.toContain('登记实收'); expect(wrapper.text()).not.toContain('分配托盘')
expect(current.status).toBe('PENDING_DISPATCH')
expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1])
})
it('requires a rejection reason and displays the resulting rejected state', async () => {
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核')
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车')
formModel(wrapper).decision = 'REJECT'; await confirm(wrapper)
expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请填写拒绝或取消原因')
formModel(wrapper).remark = '取送时间需重新确认'
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'REJECTED', version: 2 }))
await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review', payload: expect.objectContaining({ decision: 'REJECT', remark: '取送时间需重新确认', version: 1 }) }))
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
})
it('preserves one immutable request and retries it after an ambiguous failure, then refreshes the detail', async () => {
vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, undefined, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND', version: 2 }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核')
vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, undefined, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'PENDING_DISPATCH', version: 2 }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车')
formModel(wrapper).remark = '核对完成'; await confirm(wrapper)
expect(wrapper.text()).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(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
await click(wrapper, '审核'); await confirm(wrapper)
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
await click(wrapper, '审核并生成排车'); await confirm(wrapper)
expect(vi.mocked(executeUsTruckCommand).mock.calls[1][2]).toEqual(original)
expect(wrapper.text()).toContain('登记实收')
expect(wrapper.text()).toContain('排车审核')
})
it('uses delivery as the last flow node and omits point aliases', async () => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'COMPLETED' }))
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.findAll('.truck-flow-label').map(node => node.text())).toEqual(['待审核', '排车审核', '取货', '派送中', '送达'])
expect(wrapper.findAll('.truck-flow li.done')).toHaveLength(5)
expect(wrapper.text()).not.toMatch(/B点|C点|B\/C/)
})
it('locks duplicate submission until the original response arrives', async () => {
let finish!: (value: LocalTransportDetail) => void
vi.mocked(executeUsTruckCommand).mockImplementation(() => new Promise(resolve => { finish = resolve }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核'); await confirm(wrapper); await confirm(wrapper)
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车'); await confirm(wrapper); await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true)
expect(wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).props('confirmLoading')).toBe(true)
for (const button of wrapper.find('.detail-page-layout__header').findAll('button')) expect(button.attributes('disabled')).toBeDefined()
finish(order({ status: 'WAITING_INBOUND', version: 2 })); await flushPromises()
expect(wrapper.text()).toContain('登记实收')
finish(order({ status: 'PENDING_DISPATCH', version: 2 })); await flushPromises()
expect(wrapper.text()).toContain('排车审核')
})
it('validates cumulative receipt totals without submitting invalid input', async () => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'RECEIVING', palletCount: 3, receivedPalletCount: 2 }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '登记实收')
formModel(wrapper).actualPalletCount = 1; await confirm(wrapper)
expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('不能少于已实收托数')
Object.assign(formModel(wrapper), { actualPalletCount: 3, packageCount: -1 }); await confirm(wrapper)
expect(executeUsTruckCommand).not.toHaveBeenCalled()
})
it('hides obsolete pallet actions after approval and keeps the standalone order read-only', async () => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'PENDING_DISPATCH', version: 2 }))
const wrapper = mountDetail(); await flushPromises()
it('requires received pallets and permits partial allocation using their string IDs', async () => {
const pallet = { id: '9007199254741007', sequenceNo: 1, palletNo: 'P-1', source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: null }
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', palletCount: 2, receivedPalletCount: 2, pallets: [pallet] }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '分配托盘'); await confirm(wrapper)
expect(executeUsTruckCommand).not.toHaveBeenCalled()
formModel(wrapper).palletIds = [pallet.id]
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'PARTIALLY_ALLOCATED', version: 2, allocatedPalletCount: 1, pallets: [{ ...pallet, status: 'ALLOCATED' }] }))
await confirm(wrapper)
expect(wrapper.text()).toContain('部分分配'); expect(wrapper.text()).not.toContain('确认待派车')
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'allocate-pallets', payload: expect.objectContaining({ palletIds: [pallet.id] }) }))
})
it.each(['APPROVE', 'REJECT'] as const)('reviews pallet overage with %s', async decision => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'PALLET_OVERAGE_REVIEW', palletCount: 2, receivedPalletCount: 3 }))
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', version: 2 }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '超托审核')
Object.assign(formModel(wrapper), { decision, remark: '已核对超收货物' }); await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review-pallet-overage', payload: expect.objectContaining({ decision }) }))
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 () => {
@@ -177,7 +162,7 @@ describe('truck review and fulfillment', () => {
it('keeps list-only users read-only and guards direct attempts to emit submit', async () => {
const wrapper = mountDetail(false); await flushPromises()
expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0)
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
expect(executeUsTruckCommand).not.toHaveBeenCalled()
})
@@ -3,7 +3,7 @@ import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import { ArrowLeft, Check, MapPin } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
@@ -14,8 +14,9 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getUsTruckOrder } from '../api/platformOverseasBusiness'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import TruckOrderActions from '../components/TruckOrderActions.vue'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { truckStatusLabels } from '../truckWorkflow'
import type { LocalTransportDetail, LocalTransportPallet, OverseasBusinessHistory } from '../types'
import type { LocalTransportDetail, OverseasBusinessHistory } from '../types'
const route = useRoute(), router = useRouter()
const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
@@ -23,33 +24,43 @@ const customerId = computed(() => typeof route.query.customerId === 'string' ? r
const detail = ref<LocalTransportDetail | null>(null), loading = ref(false), saving = ref(false)
const error = ref<ApiErrorDetails | null>(null)
let requestSequence = 0
const flowSteps = ['待审核', '排车审核', '取货', '派送中', '送达']
const dateTime = (value: string | null | undefined) => value ? value.replace('T', ' ').slice(0, 16) : '—'
const statusLabel = (value: string | null) => value ? truckStatusLabels[value as keyof typeof truckStatusLabels] || value : '—'
const flowIndex = computed(() => {
const status = detail.value?.status
if (status === 'PENDING_REVIEW') return 0
if (status === 'IN_TRANSIT') return 3
if (status === 'COMPLETED') return 4
if (status === 'CANCELLED' || status === 'REJECTED') return 0
return 1
})
const fields = computed(() => detail.value ? [
['客户编号', detail.value.customerId], ['车辆类型', detail.value.vehicleType ? ({ PALLET_15: '15拖', PALLET_30: '30拖' })[detail.value.vehicleType] : '—'],
['起运地', detail.value.origin], ['起运地详细地址', detail.value.originAddress], ['起运地联系人', detail.value.originContactName], ['起运地联系电话', detail.value.originContactPhone],
['目的地', detail.value.destination], ['目的地详细地址', detail.value.destinationAddress], ['目的地联系人', detail.value.destinationContactName], ['目的地联系电话', detail.value.destinationContactPhone],
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
['申报件数', detail.value.packageCount], ['总重量(kg)', detail.value.totalWeightKg], ['总体积(m³)', detail.value.totalVolumeCbm],
['车辆类型', detail.value.vehicleType ? ({ PALLET_15: '15拖', PALLET_30: '30拖' })[detail.value.vehicleType] : '—'],
['取货地', detail.value.origin], ['取货地址', detail.value.originAddress],
['取货联系人', detail.value.originContactName], ['取货联系电话', detail.value.originContactPhone],
['送达地', detail.value.destination], ['送达地址', detail.value.destinationAddress],
['送达联系人', detail.value.destinationContactName], ['送达联系电话', detail.value.destinationContactPhone],
['件数', detail.value.packageCount], ['重量(kg)', detail.value.totalWeightKg], ['体积(m³)', detail.value.totalVolumeCbm],
['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`],
['已确认托数', detail.value.palletCount], ['累计实收托数', detail.value.receivedPalletCount], ['已分配托数', detail.value.allocatedPalletCount],
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark],
] : [])
const palletLabels: Record<string, string> = { EXPECTED: '待实收', PLANNED: '待实收', RECEIVED: '已实收', ALLOCATED: '已分配', OVERAGE_PENDING_REVIEW: '超托待审', OVERAGE_REJECTED: '超托拒收' }
const palletColumns: DataTableColumn[] = [
{ key: 'palletNo', prop: 'palletNo', label: '托盘编号', minWidth: 180 },
{ key: 'status', label: '状态', width: 130, formatter: (row: LocalTransportPallet) => palletLabels[row.status] || row.status },
{ key: 'packageCount', prop: 'packageCount', label: '实收件数', width: 120 },
{ key: 'receivedAt', label: '实收时间', minWidth: 170, formatter: (row: LocalTransportPallet) => dateTime(row.receivedAt) },
]
const operations: Record<string, string> = { CREATE: '创建', UPDATE: '编辑', REVIEW: '订单审核', RECEIVE_PALLETS: '登记实收', REVIEW_PALLET_OVERAGE: '超托审核', ALLOCATE_PALLETS: '分配托盘', PREPARE_DISPATCH: '确认待派车', DISPATCH: '确认已派车', START_TRANSIT: '确认发运', COMPLETE: '确认完成', CANCEL: '取消订单' }
const operations: Record<string, string> = {
CREATE: '创建', UPDATE: '编辑', REVIEW: '审核', TRANSPORT_ASSIGN: '接入排车',
RECEIVE_PALLETS: '取货', START_TRANSIT: '派送中', COMPLETE: '送达', LOCAL_TRUCK_DEPART: '取货完成', LOCAL_TRUCK_COMPLETE: '送达', CANCEL: '取消',
DISPATCH: '接入排车', PREPARE_DISPATCH: '接入排车',
}
const historyColumns: DataTableColumn[] = [
{ key: 'createdAt', label: '操作时间', minWidth: 170, formatter: (row: OverseasBusinessHistory) => dateTime(row.createdAt) },
{ key: 'operationType', label: '操作', minWidth: 130, formatter: (row: OverseasBusinessHistory) => operations[row.operationType || ''] || row.operationType || '—' },
{ key: 'fromStatus', label: '原状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.fromStatus) },
{ key: 'toStatus', label: '新状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.toStatus) },
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 140 }, { key: 'remark', prop: 'remark', label: '审核意见/操作说明', minWidth: 240 },
{ key: 'createdAt', label: '时间', minWidth: 170, formatter: (row: OverseasBusinessHistory) => dateTime(row.createdAt) },
{ key: 'operationType', label: '操作', minWidth: 120, formatter: (row: OverseasBusinessHistory) => operations[row.operationType || ''] || row.operationType || '—' },
{ key: 'fromStatus', label: '原状态', width: 120, formatter: (row: OverseasBusinessHistory) => statusLabel(row.fromStatus) },
{ key: 'toStatus', label: '新状态', width: 120, formatter: (row: OverseasBusinessHistory) => statusLabel(row.toStatus) },
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 120 },
{ key: 'remark', prop: 'remark', label: '说明', minWidth: 220 },
]
async function load() {
if (saving.value) return
const sequence = ++requestSequence
@@ -83,10 +94,21 @@ watch([orderNo, customerId], load, { immediate: true })
<LoadErrorState v-if="error" title="本土卡车订单暂时无法加载" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-skeleton v-else-if="loading" :rows="8" animated />
<template v-else-if="detail">
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
<section class="truck-detail-section"><h2>托盘记录</h2><div class="workbench-table"><DataTable :data="detail.pallets" :columns="palletColumns" empty-text="暂无托盘记录,独立订单首次实收后生成托盘" /></div></section>
<section class="truck-detail-section"><h2>审核与操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /><p class="truck-detail-note">取货和送达照片请在排车任务中上传。</p></section>
<section class="truck-detail-section truck-flow-section" aria-label="运输流程">
<div class="truck-section-heading"><h2>运输流程</h2><span class="truck-flow-caption">审核通过后由排车单推进取货、派送和送达</span></div>
<ol class="truck-flow">
<li v-for="(step, index) in flowSteps" :key="step" :class="{ active: index === flowIndex, done: index < flowIndex || detail.status === 'COMPLETED' }">
<span class="truck-flow-marker"><Check v-if="index < flowIndex || detail.status === 'COMPLETED'" :size="14" /><MapPin v-else-if="index === 2 || index === 4" :size="14" /><span v-else>{{ index + 1 }}</span></span>
<span class="truck-flow-label">{{ step }}</span>
</li>
</ol>
</section>
<section class="truck-detail-section"><div class="truck-section-heading"><h2>订单资料</h2><span class="truck-flow-caption">基础信息与货量</span></div><dl class="truck-detail-grid"><div v-for="[label, value] in fields" :key="String(label)" class="truck-detail-fact"><dt>{{ label }}</dt><dd>{{ value ?? '—' }}</dd></div></dl></section>
<section class="truck-detail-section"><BusinessDocumentFees business-type="US_TRUCK" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
<section class="truck-detail-section"><div class="truck-section-heading"><h2>审核与操作记录</h2><span class="truck-flow-caption">保留关键节点</span></div><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
</template>
</DetailPageLayout></AdminPage>
</template>
@@ -97,4 +119,25 @@ watch([orderNo, customerId], load, { immediate: true })
.truck-detail-header .status-badge { flex-shrink: 0; }
.truck-detail-actions { flex-wrap: wrap; }
.truck-detail-section { margin-bottom: var(--admin-space-6); }
.truck-section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.truck-section-heading h2 { margin: 0; font-size: 16px; color: var(--cc-text); }
.truck-flow-caption, .truck-detail-note { color: var(--cc-muted); font-size: 12px; }
.truck-detail-note { margin: 10px 0 0; }
.truck-flow { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; margin: 0; padding: 0; list-style: none; }
.truck-flow li { position: relative; display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 0; color: var(--cc-muted); text-align: center; }
.truck-flow li::before { content: ''; position: absolute; top: 15px; left: -50%; width: 100%; height: 1px; background: var(--cc-border); }
.truck-flow li:first-child::before { display: none; }
.truck-flow li.done::before, .truck-flow li.active::before { background: var(--el-color-primary); }
.truck-flow-marker { position: relative; z-index: 1; display: grid; place-items: center; width: 30px; height: 30px; border: 1px solid var(--cc-border); border-radius: 50%; background: var(--cc-surface); color: var(--cc-muted); font-size: 12px; }
.truck-flow li.done .truck-flow-marker, .truck-flow li.active .truck-flow-marker { border-color: var(--el-color-primary); color: var(--el-color-primary); }
.truck-flow li.active .truck-flow-marker { background: var(--el-color-primary); color: #fff; box-shadow: 0 0 0 4px color-mix(in srgb, var(--el-color-primary) 14%, transparent); }
.truck-flow-label { font-size: 12px; white-space: nowrap; }
.truck-flow li.active .truck-flow-label { color: var(--el-color-primary); font-weight: 600; }
.truck-detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-lg); overflow: hidden; background: var(--cc-border); }
.truck-detail-fact { min-width: 0; display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 10px; padding: 11px 14px; background: var(--cc-surface); }
.truck-detail-fact dt { color: var(--cc-muted); font-size: 12px; }
.truck-detail-fact dd { min-width: 0; margin: 0; color: var(--cc-text); overflow-wrap: anywhere; font-size: 13px; }
.truck-detail-fact:last-child { grid-column: span 2; }
@media (max-width: 1100px) { .truck-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 760px) { .truck-flow { overflow-x: auto; grid-template-columns: repeat(5, 100px); padding-bottom: 4px; } .truck-detail-grid { grid-template-columns: 1fr; } .truck-detail-fact:last-child { grid-column: auto; } }
</style>
@@ -0,0 +1,9 @@
export type ProcurementDirection = 'OVERSEAS_TO_DOMESTIC' | 'DOMESTIC_TO_OVERSEAS'
export const procurementDirections: Record<ProcurementDirection, string> = {
OVERSEAS_TO_DOMESTIC: '海外采购,发往国内', DOMESTIC_TO_OVERSEAS: '国内采购,发往海外',
}
export function directionLabel(direction?: string | null) { return direction === 'DOMESTIC_TO_OVERSEAS' ? procurementDirections.DOMESTIC_TO_OVERSEAS : procurementDirections.OVERSEAS_TO_DOMESTIC }
// 两个历史段编码表示起运段和目的地段,展示区域按订单方向转换。
export function regionText(text: string, direction?: string | null) {
return direction === 'DOMESTIC_TO_OVERSEAS' ? text.replaceAll('海外', '__ORIGIN__').replaceAll('国内', '海外').replaceAll('__ORIGIN__', '国内') : text
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { requireReviewedProcurementFees } from './procurementOutboundFees'
describe('procurement outbound fee gate', () => {
const approved = { direction: 'RECEIVABLE' as const, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' }
it('requires a current receivable, excluding voided fees and payables', () => {
for (const rows of [[], [{ ...approved, sourceStatus: 'VOIDED' }], [{ ...approved, direction: 'PAYABLE' as const }]]) {
expect(() => requireReviewedProcurementFees(rows)).toThrow('请先添加费用明细')
}
})
it('requires every current fee to be active and approved', () => {
for (const reviewStatus of ['PENDING', 'REJECTED']) {
expect(() => requireReviewedProcurementFees([approved, { ...approved, reviewStatus }])).toThrow('尚未全部审核通过')
}
expect(() => requireReviewedProcurementFees([approved, { ...approved, sourceStatus: 'VOIDING' }])).toThrow('尚未全部审核通过')
expect(() => requireReviewedProcurementFees([approved, { ...approved, sourceStatus: 'VOIDED', reviewStatus: 'REJECTED' }])).not.toThrow()
})
})
@@ -0,0 +1,7 @@
import type { OverseasFee } from './api/overseasFees'
export function requireReviewedProcurementFees(rows: Pick<OverseasFee, 'direction' | 'sourceStatus' | 'reviewStatus'>[]) {
const fees = rows.filter(fee => fee.direction === 'RECEIVABLE' && fee.sourceStatus !== 'VOIDED')
if (!fees.length) throw new Error('请先添加费用明细并审核通过后再出库')
if (fees.some(fee => fee.sourceStatus !== 'ACTIVE' || fee.reviewStatus !== 'APPROVED')) throw new Error('费用明细尚未全部审核通过,请先完成费用审核后再出库')
}
@@ -5,3 +5,122 @@ export function commandKey() {
const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes)
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
}
export function readPendingProcurement(raw: string | null): PendingProcurement | null {
if (raw === null) return null
const value: unknown = JSON.parse(raw)
if (!value || typeof value !== 'object' || !('command' in value) || !('payload' in value) || !commands.has(String(value.command))) throw new Error('invalid pending command')
const payload = value.payload
if (!payload || typeof payload !== 'object' || !('version' in payload) || !Number.isSafeInteger(payload.version) || Number(payload.version) < 0 || !('idempotencyKey' in payload) || typeof payload.idempotencyKey !== 'string' || !payload.idempotencyKey) throw new Error('invalid pending payload')
const fields = new Set(['version', 'idempotencyKey', 'reason', 'decision', 'remark', 'purchaseWarehouseId', 'procurementDirection', 'warehouseId', 'productName', 'specification', 'quantity', 'amount', 'currency', 'purchasedAt', 'estimatedArrivalAt', 'voucherFileId', 'leg', 'sourceCartonId', 'receiptId', 'dispositionId', 'goodQuantity', 'damagedQuantity', 'extraQuantity', 'receivedAt', 'kind', 'packingVersion', 'cartons', 'labelVersion', 'receiver', 'addressVoucherFileId', 'labels', 'cartonIds', 'businessDate', 'appliedAt', 'shippedAt', 'cartonId', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'feeId', 'targetOriginalAmount', 'internationalFreight', 'duty', 'domesticFreight', 'otherDetermination', 'otherFees', 'targetType', 'targetId', 'blocking', 'clientSummary', 'noteId', 'resolutions', 'customerVoucherFileId', 'financialConclusion', 'name'])
const body = payload as Record<string, unknown>
if (Object.keys(body).some(field => !fields.has(field))) throw new Error('unexpected pending field')
const nonempty = (field: string) => typeof body[field] === 'string' && !!body[field].trim()
const positiveId = (field: string) => nonempty(field) && /^[1-9]\d*$/.test(String(body[field]))
if (body.procurementDirection !== undefined && !['OVERSEAS_TO_DOMESTIC', 'DOMESTIC_TO_OVERSEAS'].includes(String(body.procurementDirection))) throw new Error('invalid procurement direction')
if (value.command === 'review') {
if (!['APPROVE', 'REJECT'].includes(String(body.decision)) || (body.decision === 'APPROVE' && !positiveId('purchaseWarehouseId'))) throw new Error('invalid pending review')
} else if (!nonempty('reason')) throw new Error('missing pending reason')
if (value.command === 'planned-warehouse' && !positiveId('warehouseId')) throw new Error('invalid pending warehouse')
if (['purchase', 'budget-authorizations'].includes(String(value.command))) {
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !['CNY', 'USD'].includes(String(body.currency)) || !positiveId('voucherFileId')) throw new Error('invalid pending amount')
}
if (value.command === 'purchase' && (!nonempty('productName') || !Number.isSafeInteger(body.quantity) || Number(body.quantity) < 1 || !nonempty('purchasedAt') || !nonempty('estimatedArrivalAt'))) throw new Error('invalid pending purchase')
const count = (field: string, minimum: number) => Number.isSafeInteger(body[field]) && Number(body[field]) >= minimum
if (['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'receipts/correct', 'dispositions'].includes(String(value.command))) {
if (!['OVERSEAS', 'DOMESTIC'].includes(String(body.leg))) throw new Error('invalid receipt leg')
if ((String(value.command).startsWith('overseas') && body.leg !== 'OVERSEAS') || (String(value.command).startsWith('domestic') && body.leg !== 'DOMESTIC')) throw new Error('wrong receipt endpoint')
}
if (['overseas-receipts', 'domestic-receipts', 'receipts/correct'].includes(String(value.command))) {
if (!['goodQuantity', 'damagedQuantity', 'extraQuantity'].every(field => count(field, 0)) || Number(body.goodQuantity) + Number(body.damagedQuantity) + Number(body.extraQuantity) <= 0 || !nonempty('receivedAt')) throw new Error('invalid pending receipt')
if (body.leg === 'OVERSEAS' ? !positiveId('warehouseId') || body.sourceCartonId != null : !positiveId('sourceCartonId') || body.warehouseId != null) throw new Error('invalid receipt source')
}
if (['receipts/correct', 'dispositions', 'dispositions/revoke'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing correction evidence')
if (value.command === 'receipts/correct' && !positiveId('receiptId')) throw new Error('missing original receipt')
if (value.command === 'dispositions/revoke' && !positiveId('dispositionId')) throw new Error('missing original disposition')
if (value.command === 'dispositions') {
const decisions: Record<string, string> = { SHORTAGE: 'ACCEPT_SHORTAGE', DAMAGED: 'DISPOSE_DAMAGED', EXTRA: 'RETURN_EXTRA' }
if (!count('quantity', 1) || !decisions[String(body.kind)] || decisions[String(body.kind)] !== body.decision || (body.kind !== 'SHORTAGE' && !positiveId('receiptId')) || (body.kind === 'SHORTAGE' && body.receiptId != null) || (body.leg === 'DOMESTIC' && !positiveId('sourceCartonId'))) throw new Error('invalid pending disposition')
}
if (value.command === 'packing/confirm' && !count('packingVersion', 1)) throw new Error('invalid packing version')
if (value.command === 'packing') {
if (!Array.isArray(body.cartons) || !body.cartons.length) throw new Error('missing packing list')
const names = new Set<string>()
for (const item of body.cartons) {
if (!item || typeof item !== 'object' || typeof item.cartonNo !== 'string' || !item.cartonNo.trim() || names.has(item.cartonNo) || Object.keys(item).some(key => !['cartonNo', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'sources'].includes(key))) throw new Error('invalid packing carton')
names.add(item.cartonNo)
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
if (typeof item[field] !== 'string' || !pattern.test(item[field]) || Number(item[field]) <= 0) throw new Error('invalid packing measurement')
}
if (!Array.isArray(item.sources) || !item.sources.length || item.sources.some((source: unknown) => {
if (!source || typeof source !== 'object') return true
const row = source as Record<string, unknown>
return Object.keys(row).some(key => !['receiptId', 'quantity'].includes(key)) || typeof row.receiptId !== 'string' || !/^[1-9]\d*$/.test(row.receiptId) || !Number.isSafeInteger(row.quantity) || Number(row.quantity) < 1
})) throw new Error('invalid packing source')
}
}
if (value.command === 'labels') {
const receiver = body.receiver
if (!count('packingVersion', 1) || !['OVERSEAS', 'DOMESTIC'].includes(String(body.leg)) || !receiver || typeof receiver !== 'object' || !['name', 'phone', 'address'].every(key => typeof (receiver as Record<string, unknown>)[key] === 'string' && String((receiver as Record<string, unknown>)[key]).trim())) throw new Error('invalid label address')
if (!Array.isArray(body.labels) || !body.labels.length) throw new Error('missing labels')
const cartonIds = new Set<string>()
for (const row of body.labels) {
if (!row || typeof row !== 'object' || typeof row.cartonId !== 'string' || !/^[1-9]\d*$/.test(row.cartonId) || typeof row.fileId !== 'string' || !/^[1-9]\d*$/.test(row.fileId) || !Number.isSafeInteger(row.page) || row.page < 1 || !row.carrier?.trim() || !row.trackingNo?.trim() || cartonIds.has(row.cartonId)) throw new Error('invalid label association')
cartonIds.add(row.cartonId)
}
}
if (value.command === 'international-estimate' && (!count('labelVersion', 1) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !nonempty('businessDate'))) throw new Error('invalid pending estimate')
if (['labels/apply', 'overseas-shipment', 'customer-shipment'].includes(String(value.command))) {
if (!count('labelVersion', 1) || !Array.isArray(body.cartonIds) || !body.cartonIds.length || body.cartonIds.some(id => typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) || new Set(body.cartonIds).size !== body.cartonIds.length) throw new Error('invalid transport cartons')
if (!nonempty(value.command === 'labels/apply' ? 'appliedAt' : 'shippedAt')) throw new Error('missing actual transport time')
if ((value.command === 'overseas-shipment' && body.leg !== 'OVERSEAS') || (value.command === 'customer-shipment' && body.leg !== 'DOMESTIC')) throw new Error('wrong shipment leg')
}
if (value.command === 'domestic-repack') {
if (!positiveId('cartonId') || !positiveId('voucherFileId')) throw new Error('invalid repack reference')
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
if (typeof body[field] !== 'string' || !pattern.test(String(body[field])) || Number(body[field]) <= 0) throw new Error('invalid repack measurement')
}
}
if (String(value.command).startsWith('fees/') && value.command !== 'fees/additional') {
if (!positiveId('feeId')) throw new Error('invalid original fee')
if (['fees/correct-target', 'fees/refund', 'fees/replace-failed'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing finance evidence')
if (['fees/correct-target', 'fees/replace-failed'].includes(String(value.command)) && !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.targetOriginalAmount))) throw new Error('invalid correction target')
if (value.command === 'fees/refund' && (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0)) throw new Error('invalid refund amount')
}
if (value.command === 'charges/confirm') {
const selection = (input: unknown) => {
if (!input || typeof input !== 'object') throw new Error('missing fee determination')
const item = input as Record<string, unknown>
if ((item.feeId == null) === (item.value == null) || Object.keys(item).some(key => !['feeId', 'value'].includes(key))) throw new Error('ambiguous fee determination')
if (item.feeId != null) { if (typeof item.feeId !== 'string' || !/^[1-9]\d*$/.test(item.feeId)) throw new Error('invalid fee reference'); return }
if (!item.value || typeof item.value !== 'object') throw new Error('invalid fee amount')
const value = item.value as Record<string, unknown>
if (Object.keys(value).some(key => !['determination', 'amount', 'currency', 'businessDate', 'voucherFileId', 'reason'].includes(key))) throw new Error('unexpected fee amount field')
if (!['POSITIVE', 'ZERO', 'NOT_APPLICABLE'].includes(String(value.determination)) || typeof value.amount !== 'string' || !/^\d{1,16}(?:\.\d{1,2})?$/.test(value.amount) || (value.determination === 'POSITIVE' ? Number(value.amount) <= 0 : Number(value.amount) !== 0) || !['CNY', 'USD'].includes(String(value.currency)) || typeof value.businessDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.businessDate) || typeof value.reason !== 'string' || !value.reason.trim()) throw new Error('invalid fee determination value')
if (value.determination === 'POSITIVE' && (typeof value.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(value.voucherFileId))) throw new Error('missing fee voucher')
}
for (const key of ['internationalFreight', 'duty', 'domesticFreight']) selection(body[key])
if (!['NOT_APPLICABLE', 'ITEMIZED'].includes(String(body.otherDetermination)) || !Array.isArray(body.otherFees) || (body.otherDetermination === 'NOT_APPLICABLE') !== (body.otherFees.length === 0)) throw new Error('invalid other fee determination')
const codes = new Set<string>()
for (const row of body.otherFees) {
if (!row || typeof row !== 'object' || typeof row.code !== 'string' || !/^[A-Z][A-Z0-9_]{0,31}$/.test(row.code) || codes.has(row.code) || typeof row.name !== 'string' || !row.name.trim()) throw new Error('invalid other fee')
codes.add(row.code); selection(row.selection)
}
}
if (['control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing control evidence')
if (value.command === 'control-notes' && (!['PURCHASE', 'WAREHOUSE', 'RECEIPT', 'SHIPMENT'].includes(String(body.targetType)) || typeof body.blocking !== 'boolean' || (body.targetType === 'PURCHASE' ? body.targetId != null : !positiveId('targetId')))) throw new Error('invalid control target')
if (value.command === 'control-notes/resolve' && !positiveId('noteId')) throw new Error('missing original blocker')
if (value.command === 'actual-warehouse/correct' && !positiveId('warehouseId')) throw new Error('invalid actual warehouse')
if (value.command === 'exception-close') {
if (!positiveId('customerVoucherFileId') || !nonempty('financialConclusion') || !nonempty('clientSummary') || !Array.isArray(body.resolutions)) throw new Error('incomplete closure facts')
const decisions: Record<string, string[]> = { PURCHASE_REMAINDER: ['SUPPLIER_RETAINED', 'RETURNED_TO_SUPPLIER', 'LOSS_CONFIRMED'], TRANSIT_REMAINDER: ['LOSS_CONFIRMED', 'RETURNED_TO_SENDER'], GOOD: ['RETURNED_TO_SUPPLIER', 'CUSTOMER_AUTHORIZED_RETENTION', 'DISPOSED'], DAMAGED: ['DISPOSED', 'RETURNED_TO_SUPPLIER'], EXTRA: ['RETURNED_TO_SUPPLIER'] }
const sources = new Set<string>()
for (const row of body.resolutions) {
if (!row || typeof row !== 'object' || !decisions[row.kind]?.includes(row.decision) || !Number.isSafeInteger(row.quantity) || row.quantity < 1 || typeof row.destination !== 'string' || !row.destination.trim() || typeof row.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(row.voucherFileId) || (row.kind === 'PURCHASE_REMAINDER' ? row.sourceId != null : typeof row.sourceId !== 'string' || !/^[1-9]\d*$/.test(row.sourceId))) throw new Error('invalid closure resolution')
const key = `${row.kind}:${row.sourceId ?? ''}`; if (sources.has(key)) throw new Error('duplicate closure source'); sources.add(key)
}
}
if (value.command === 'fees/additional' && (!nonempty('name') || !nonempty('businessDate') || !['CNY', 'USD'].includes(String(body.currency)) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0 || !positiveId('voucherFileId'))) throw new Error('invalid independent financial fee')
return value as PendingProcurement
}
@@ -1,21 +1,20 @@
import { regionText } from './procurementDirection'
import type { Amount, ProcurementFee } from './procurementTypes'
export const procurementStatuses: Record<string, string> = {
PENDING_REVIEW: '待审核', WAIT_PURCHASE: '待购买', WAIT_OVERSEAS_RECEIPT: '待海外到仓', OVERSEAS_RECEIVING: '海外收货中',
WAIT_PACKING: '待装箱', WAIT_LABELS: '待面单及预估处理', WAIT_OVERSEAS_SHIPMENT: '待海外交寄', INTERNATIONAL_TRANSIT: '国际运输中',
DOMESTIC_RECEIVING: '国内收货中', WAIT_CHARGES: '待国内核费', WAIT_CUSTOMER_SHIPMENT: '待寄客户', SHIPPED_TO_CUSTOMER: '已寄客户',
REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭',
PENDING_REVIEW: '待审核', RECEIVING: '收货中', WAIT_CUSTOMER_CONFIRM: '待客户确认', CONFIRMED: '已确认',
IN_TRANSIT: '运输中', DELIVERING: '派送中', COMPLETED: '签收完成',
}
export const feePurposes: Record<string, string> = {
SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费',
DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款',
}
export function statusLabel(status: string | null | undefined) { return status ? procurementStatuses[status] || '待核对进度' : '—' }
export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || ({ REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭' } as Record<string, string>)[status] || '待核对进度' : '—', direction) }
export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
if (status === 'SHIPPED_TO_CUSTOMER') return 'success'
if (status === 'COMPLETED') return 'success'
if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger'
if (status === 'CANCELLED') return 'info'
if (status === 'INTERNATIONAL_TRANSIT') return 'primary'
if (status === 'IN_TRANSIT' || status === 'DELIVERING') return 'primary'
return 'warning'
}
export function cents(value: Amount | null | undefined): bigint | null {
@@ -1,5 +1,6 @@
export type Amount = number | string
export interface ProcurementSummary {
procurementDirection?: import('./procurementDirection').ProcurementDirection
orderNo: string
purchaseCategory: string
productName: string
+4 -2
View File
@@ -5,6 +5,7 @@ import { overseasBusinessRoutes } from './routes'
describe('overseas business routes', () => {
it('keeps the frozen paths and permissions', () => {
expect(overseasBusinessRoutes.map((route) => [route.path, route.meta?.permission])).toEqual([
['overseas-business/fees', 'platform:overseas-fee:list'],
['overseas-business/purchases', permission.purchaseList],
['overseas-business/purchases/:purchaseId', permission.purchaseList],
['overseas-business/purchases/:purchaseId/edit', permission.purchaseEdit],
@@ -14,15 +15,16 @@ describe('overseas business routes', () => {
['overseas-business/trucks/:orderId/edit', permission.truckEdit],
['overseas-business/looseboards', permission.looseboardList],
['overseas-business/looseboards/new', permission.looseboardCreate],
['overseas-business/looseboards/:orderId', permission.looseboardList],
['overseas-business/looseboards/:orderId/edit', permission.looseboardEdit],
])
})
it('adds the requested truck detail without opening unrelated routes', () => {
it('opens both transport document details without granting edit access', () => {
const paths = overseasBusinessRoutes.map((route) => route.path)
expect(paths).not.toContain('overseas-business/purchases/new')
expect(paths).toContain('overseas-business/trucks/:orderId')
expect(paths).not.toContain('overseas-business/looseboards/:orderId')
expect(paths).toContain('overseas-business/looseboards/:orderId')
expect(JSON.stringify(overseasBusinessRoutes)).not.toContain('permissionAnyOf')
})
})
+12
View File
@@ -2,6 +2,12 @@ import type { RouteRecordRaw } from 'vue-router'
import { overseasBusinessPermissions as permission } from './permissions'
export const overseasBusinessRoutes = [
{
path: 'overseas-business/fees',
name: 'overseas-business-fees',
component: () => import('./pages/OverseasFeesView.vue'),
meta: { title: '海外费用明细', permission: 'platform:overseas-fee:list' },
},
{
path: 'overseas-business/purchases',
name: 'overseas-business-purchases',
@@ -61,6 +67,12 @@ export const overseasBusinessRoutes = [
props: { domain: 'looseboard' },
meta: { title: '新增本土散板订单', description: '创建海外本土散板订单', permission: permission.looseboardCreate, showPageHeading: false },
},
{
path: 'overseas-business/looseboards/:orderId',
name: 'overseas-business-looseboard-detail',
component: () => import('./pages/LooseboardOrderDetailView.vue'),
meta: { title: '本土散板订单详情', permission: permission.looseboardList, showPageHeading: false },
},
{
path: 'overseas-business/looseboards/:orderId/edit',
name: 'overseas-business-looseboard-edit',
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { storageRentBillingShare, storageRentChargeStatus, storageRentGeneratedAmounts, storageRentSummaryStatus, storageRentTime } from './storageRentPresentation'
import type { StorageRentLot } from './api/storageRent'
it('distinguishes missing charges, genuine free rent and incomplete settlement', () => {
const lot = { status: 'CLEARED', originalQuantity: 4, billingQuantity: 1, billingUnit: 'PALLET', chargeCount: 2, settledChargeCount: 2, settledAmount: 10 } as StorageRentLot
expect(storageRentSummaryStatus(lot)).toBe('已扣款')
expect(storageRentSummaryStatus({ ...lot, settledChargeCount: 1 })).toBe('有待完成扣款')
expect(storageRentSummaryStatus({ ...lot, settledAmount: 0 })).toBe('免租 · 已结算')
expect(storageRentSummaryStatus({ ...lot, chargeCount: 0 })).toBe('仓租记录待核对')
expect(storageRentBillingShare(2, lot)).toBe('0.5 托')
})
describe('仓租原币汇总显示', () => {
it('displays server currency groups without adding different currencies', () => {
expect(storageRentGeneratedAmounts({ generatedAmounts: [{ currency: 'USD', amount: 12 }, { currency: 'CNY', amount: 23 }], generatedAmount: null, generatedCurrency: null })).toEqual(['USD 12.00', 'CNY 23.00'])
expect(storageRentGeneratedAmounts({ generatedAmounts: [{ currency: 'EUR', amount: 0 }], generatedAmount: 0, generatedCurrency: 'EUR' })).toEqual(['EUR 0.00'])
})
it('keeps scalar single currency totals and empty totals free of assumed currencies', () => {
expect(storageRentGeneratedAmounts({ generatedAmounts: [], generatedAmount: 8.5, generatedCurrency: 'CNY' })).toEqual(['CNY 8.50'])
expect(storageRentGeneratedAmounts({ generatedAmounts: [], generatedAmount: 0, generatedCurrency: null })).toEqual(['0.00'])
expect(storageRentGeneratedAmounts({ generatedAmounts: [], generatedAmount: null, generatedCurrency: null })).toEqual(['—'])
})
})
describe('仓租扣费状态', () => {
it('labels only settled zero-original-amount charges as rent free', () => {
expect(storageRentChargeStatus({ status: 'SETTLED', originalAmount: 0 })).toBe('免租')
expect(storageRentChargeStatus({ status: 'SETTLED', originalAmount: '0.00' })).toBe('免租')
expect(storageRentChargeStatus({ status: 'SETTLED', originalAmount: 12 })).toBe('已扣费')
expect(storageRentChargeStatus({ status: 'SETTLED', originalAmount: null })).toBe('已扣费')
expect(storageRentChargeStatus({ status: 'CONSUMED', originalAmount: 0 })).toBe('扣费中')
expect(storageRentChargeStatus({ status: 'SETTLE_FAILED', originalAmount: 0 })).toBe('扣费失败')
})
})
describe('仓租带时区时间显示', () => {
it('converts UTC and equivalent offset timestamps before displaying local business dates', () => {
expect(storageRentTime('2026-10-22T18:30:45Z', 'Asia/Shanghai')).toBe('2026-10-23 02:30:45')
expect(storageRentTime('2026-10-23T02:30:45+08:00', 'Asia/Shanghai')).toBe('2026-10-23 02:30:45')
expect(storageRentTime('2026-10-22T02:30:45Z', 'America/Los_Angeles')).toBe('2026-10-21 19:30:45')
})
it('defaults to the browser timezone and handles missing or invalid values', () => {
const value = '2026-10-22T18:30:45Z'
expect(storageRentTime(value)).toBe(storageRentTime(value, Intl.DateTimeFormat().resolvedOptions().timeZone))
expect(storageRentTime(null)).toBe('—')
expect(storageRentTime('invalid')).toBe('—')
})
})
@@ -0,0 +1,41 @@
import { feeMoney } from './overseasFeePresentation'
import type { StorageRentLot } from './api/storageRent'
export function storageRentSourceType(type?: string | null) {
const labels: Record<string, string> = { LASTLEG_ORDER: '头程尾程订单', WAREHOUSE_TRANSFER: '仓储转运单', RETURN_ORDER: '退货单' }
return type ? labels[type] || type : '—'
}
export function storageRentGeneratedAmounts(lot: Pick<StorageRentLot, 'generatedAmounts' | 'generatedAmount' | 'generatedCurrency'>) {
if (lot.generatedAmounts?.length) return lot.generatedAmounts.map(item => feeMoney(item.amount, item.currency))
return [feeMoney(lot.generatedAmount, lot.generatedCurrency || '')]
}
export function storageRentChargeStatus(charge: { status: string; originalAmount: number | string | null }) {
if (charge.status === 'SETTLED' && charge.originalAmount != null && Number(charge.originalAmount) === 0) return '免租'
const labels: Record<string, string> = { PENDING_REVIEW: '待审核', REJECTED: '已驳回', REVIEW_FREEZE_PENDING: '调整锁定中', REVIEW_FREEZE_FAILED: '调整锁定失败', FREEZE_PENDING: '锁定中', READY: '待出库', CONSUMED: '扣费中', SETTLED: '已扣费', SETTLE_FAILED: '扣费失败', FAILED: '锁定失败', EXPIRED: '已过期', CANCELLED: '已取消' }
return labels[charge.status] || charge.status
}
export function storageRentSummaryStatus(lot: StorageRentLot) {
if (lot.chargeCount == null) return '待核对'
if (!Number(lot.chargeCount)) return lot.status === 'CLEARED' ? '仓租记录待核对' : '尚未出库计租'
if (Number(lot.settledChargeCount) < Number(lot.chargeCount)) return '有待完成扣款'
return Number(lot.settledAmount) === 0 ? '免租 · 已结算' : '已扣款'
}
export function storageRentBillingShare(quantity: number, lot: StorageRentLot) {
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}`
}
export function storageRentTime(value?: string | null, timeZone?: string) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('sv-SE', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23', timeZone,
}).format(date)
}
@@ -32,10 +32,14 @@ export function createTransportDemandForm(): TransportDemandForm {
}
}
const requiredText = {
origin: '起运地', destination: '目的地', originContactName: '起运地联系人',
originContactPhone: '起运地联系电话', destinationContactName: '目的地联系人', destinationContactPhone: '目的地联系电话',
} as const
const requiredText = (kind: TransportDemandKind) => ({
origin: kind === 'truck' ? 'B点取货地' : '起运地',
destination: kind === 'truck' ? 'C点送达地' : '目的地',
originContactName: kind === 'truck' ? 'B点联系人' : '起运地联系人',
originContactPhone: kind === 'truck' ? 'B点联系电话' : '起运地联系电话',
destinationContactName: kind === 'truck' ? 'C点联系人' : '目的地联系人',
destinationContactPhone: kind === 'truck' ? 'C点联系电话' : '目的地联系电话',
} as const)
export function localMinuteInput(value: unknown) {
return typeof value === 'string' && value ? `${value.slice(0, 16)}:00` : ''
@@ -43,8 +47,9 @@ export function localMinuteInput(value: unknown) {
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
const rules: FormRules<TransportDemandForm> = {}
for (const [field, label] of Object.entries(requiredText)) {
rules[field as keyof typeof requiredText] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
const labels = requiredText(kind)
for (const [field, label] of Object.entries(labels)) {
rules[field as keyof typeof labels] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
}
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
@@ -52,23 +57,26 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
}
rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }]
rules.pickupAt = [{ required: true, message: '请选择取货时间', trigger: 'change' }]
rules.deliveryAt = [{ required: true, message: '请选择送货时间', trigger: 'change' }, {
validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change',
const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间'
rules.pickupAt = [{ required: true, message: `请选择${pickupLabel}`, trigger: 'change' }]
rules.deliveryAt = [{ required: true, message: `请选择${deliveryLabel}`, trigger: 'change' }, {
validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error(`${deliveryLabel}不能早于${pickupLabel}`) : undefined), trigger: 'change',
}]
return rules
}
export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined {
if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型'
for (const [field, label] of Object.entries(requiredText)) {
if (!String(form[field as keyof typeof requiredText] || '').trim()) return `请输入${label}`
const labels = requiredText(kind)
for (const [field, label] of Object.entries(labels)) {
if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}`
}
for (const [field, label] of [['pickupAt', '取货时间'], ['deliveryAt', '送货时间']] as const) {
const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间'
for (const [field, label] of [['pickupAt', pickupLabel], ['deliveryAt', deliveryLabel]] as const) {
if (!form[field]) return `请选择${label}`
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}`
}
if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return '送货时间不能早于取货时间'
if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return `${deliveryLabel}不能早于${pickupLabel}`
for (const [field, label] of [['packageCount', '件数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积'], ...(kind === 'looseboard' ? [['estimatedPalletCount', '预计托数'] as const] : [])] as const) {
if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字`
}
+13 -18
View File
@@ -1,40 +1,35 @@
import type { LocalTransportDetail, LocalTransportStatus } from './types'
export const truckStatusLabels: Record<LocalTransportStatus, string> = {
PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托',
PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配',
PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消',
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: '审核', 'receive-pallets': '登记实收', 'review-pallet-overage': '超托审核', 'allocate-pallets': '分配托盘',
review: '审核并生成排车', arrange: '补建排车单', 'receive-pallets': '登记实收', 'review-pallet-overage': '超托审核', 'allocate-pallets': '分配托盘',
'prepare-dispatch': '确认待派车', dispatch: '确认已派车', 'start-transit': '确认发运', complete: '确认完成', cancel: '取消订单',
} as const
export type TruckCommand = keyof typeof truckCommandLabels
export interface TruckCommandBase { version: number; idempotencyKey: string; remark?: string }
export type TruckOperation =
| { command: 'review' | 'review-pallet-overage'; payload: TruckCommandBase & { decision: 'APPROVE' | 'REJECT' } }
| { command: 'review'; payload: TruckCommandBase & { decision: 'APPROVE' | 'REJECT'; warehouseId?: string } }
| { command: 'arrange'; payload: TruckCommandBase & { warehouseId: string } }
| { command: 'review-pallet-overage'; payload: TruckCommandBase & { decision: 'APPROVE' | 'REJECT' } }
| { command: 'receive-pallets'; payload: TruckCommandBase & { actualPalletCount: number; packageCount?: number; palletPackageCounts?: number[] } }
| { command: 'allocate-pallets'; payload: TruckCommandBase & { palletIds: string[] } }
| { command: 'prepare-dispatch' | 'dispatch' | 'start-transit' | 'complete'; payload: TruckCommandBase }
| { command: 'cancel'; payload: TruckCommandBase & { status: 'CANCELLED' } }
const nextCommands: Partial<Record<LocalTransportStatus, TruckCommand>> = {
PENDING_REVIEW: 'review', WAITING_INBOUND: 'receive-pallets', RECEIVING: 'receive-pallets',
PALLET_OVERAGE_REVIEW: 'review-pallet-overage', WAITING_CONSOLIDATION: 'allocate-pallets',
PARTIALLY_ALLOCATED: 'allocate-pallets', ALLOCATED: 'prepare-dispatch', PENDING_DISPATCH: 'dispatch',
DISPATCHED: 'start-transit', IN_TRANSIT: 'complete',
}
const cancellable = new Set<LocalTransportStatus>(['PENDING_REVIEW', 'WAITING_INBOUND', 'RECEIVING', 'PALLET_OVERAGE_REVIEW', 'WAITING_CONSOLIDATION', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH'])
/** 拼车来源只读,独立单据只能执行当前状态对应的具名命令。 */
/** 拼车来源只读;原单只保留审核入口,排车后的执行动作统一在排车任务完成。 */
export function availableTruckCommands(order: Pick<LocalTransportDetail, 'status' | 'fleetArrangement'>): TruckCommand[] {
if (order.fleetArrangement) return []
const next = nextCommands[order.status]
return [...(next ? [next] : []), ...(cancellable.has(order.status) ? ['cancel' as const] : [])]
if (order.status === 'PENDING_REVIEW') return ['review', 'cancel']
return order.status === 'PENDING_DISPATCH' ? ['arrange', 'cancel'] : []
}
export const truckCommandHints: Record<TruckCommand, string> = {
review: '请核对车型、路线、两端联系信息、取送时间和货物资料。通过后进入待入仓,拒绝后结束本次申请。',
review: '核对基础资料后进入排车审核。审核通过后,取货照片、派送中和送达照片均在排车单中完成。',
arrange: '选择真实 A 海外仓,补建本土卡车排车单。',
'receive-pallets': '按实际收货填写截至本次的累计实收托数。首次实收确定本单托数,后续超出已确认托数需要超托审核。',
'review-pallet-overage': '通过后将超收托盘纳入本单;拒绝后将超收托盘标记为拒收,按原已确认托数继续。',
'allocate-pallets': '选择本单已实收且未分配的托盘,可分批分配;全部已接收托盘分配完成后才能进入待派车。',
@@ -42,5 +37,5 @@ export const truckCommandHints: Record<TruckCommand, string> = {
dispatch: '请在车辆安排已实际完成后确认,本单将进入已派车。',
'start-transit': '请在车辆已实际发运后确认,本单将进入运输中。',
complete: '请在运输及交付已实际完成后确认。确认完成后不能撤回。',
cancel: '取消后本单不能继续履约,请填写取消原因并核对已有收货和托盘记录。',
cancel: '取消后本单不能继续履约,请填写取消原因。',
}
+2 -1
View File
@@ -5,13 +5,14 @@ export interface OverseasBusinessHistory {
operatorId: string | null; changeDetail?: string | null; remark: string | null; createdAt: string
}
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'WAIT_PURCHASE' | 'WAIT_OVERSEAS_RECEIPT' | 'OVERSEAS_RECEIVING' | 'WAIT_PACKING' | 'WAIT_LABELS' | 'WAIT_OVERSEAS_SHIPMENT' | 'INTERNATIONAL_TRANSIT' | 'DOMESTIC_RECEIVING' | 'WAIT_CHARGES' | 'WAIT_CUSTOMER_SHIPMENT' | 'SHIPPED_TO_CUSTOMER' | 'REJECTED' | 'CANCELLED' | 'EXCEPTION_CLOSED'
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'RECEIVING' | 'WAIT_CUSTOMER_CONFIRM' | 'CONFIRMED' | 'IN_TRANSIT' | 'DELIVERING' | 'COMPLETED'
export interface OverseasPurchaseQuery {
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | ''
purchaseWarehouseId?: string; createdFrom?: string; createdTo?: string
}
export interface OverseasPurchaseSummary {
procurementDirection?: import('./procurementDirection').ProcurementDirection
id: string; orderNo: string; customerId: string; customerName: string | null
purchaseCategory: string; productName: string; productSpecification: string | null
purchaseQuantity: number; budgetUnitPrice: string | null; currency: string
@@ -0,0 +1,72 @@
import { effectScope, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OverseasFee } from './api/overseasFees'
import { feeProcessingLabel } from './overseasFeePresentation'
import { useFeeStatusRefresh } from './useFeeStatusRefresh'
const pending = () => ({ sourceFeeId: '1', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED',
publicationStatus: 'PUBLISHED', deliveryStatus: 'PENDING', walletStatus: 'NOT_REQUIRED' }) as OverseasFee
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
describe('费用最终状态自动回查', () => {
it('余额已扣成功不再等待旧费用推送,显示完成并停止回查', async () => {
const scope = effectScope(), rows = ref([{ ...pending(), walletStatus: 'SETTLED', deliveryErrorMessage: '旧推送失败' }]), enabled = ref(true)
const refresh = vi.fn(async () => {})
scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))
expect(feeProcessingLabel(rows.value[0]!)).toBe('已完成')
await vi.advanceTimersByTimeAsync(20000)
expect(refresh).not.toHaveBeenCalled()
scope.stop()
})
it.each(['PUBLISHED', 'DEAD'] as const)('读取实际 %s 回执后停止,显示最终业务结果', async deliveryStatus => {
const scope = effectScope(), rows = ref([pending()]), enabled = ref(true)
const refresh = vi.fn(async () => { rows.value = [{ ...pending(), deliveryStatus }] })
scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))
rows.value = [pending()]; await nextTick()
await vi.advanceTimersByTimeAsync(2000)
expect(refresh).toHaveBeenCalledTimes(1)
expect(feeProcessingLabel(rows.value[0]!)).toBe(deliveryStatus === 'PUBLISHED' ? '已完成' : '处理失败')
await vi.advanceTimersByTimeAsync(10000)
expect(refresh).toHaveBeenCalledTimes(1)
scope.stop()
})
it('离开当前列表或销毁组件后不再回查,回到列表可恢复', async () => {
const scope = effectScope(), rows = ref<OverseasFee[]>([]), enabled = ref(true)
const refresh = vi.fn(async () => {})
scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))
rows.value = [pending()]; await nextTick()
enabled.value = false; await nextTick()
await vi.advanceTimersByTimeAsync(2000); expect(refresh).not.toHaveBeenCalled()
enabled.value = true; await nextTick()
await vi.advanceTimersByTimeAsync(2000); expect(refresh).toHaveBeenCalledTimes(1)
scope.stop()
await vi.advanceTimersByTimeAsync(10000); expect(refresh).toHaveBeenCalledTimes(1)
})
it('长时间未完成只回查有限次数,人工重新操作后可开启新一轮', async () => {
const scope = effectScope(), rows = ref<OverseasFee[]>([]), enabled = ref(true)
const refresh = vi.fn(async () => { rows.value = [pending()] })
const control = scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))!
rows.value = [pending()]; await nextTick()
await vi.advanceTimersByTimeAsync(700000); expect(refresh).toHaveBeenCalledTimes(90)
control.reset(); rows.value = [pending()]; await nextTick()
await vi.advanceTimersByTimeAsync(2000); expect(refresh).toHaveBeenCalledTimes(91)
scope.stop()
})
it('自动重试中的失败保持可见,超过一分钟仍跟进最终接收结果', async () => {
const scope = effectScope(), rows = ref([{ ...pending(), deliveryErrorMessage: 'S0500' }]), enabled = ref(true)
const refresh = vi.fn(async () => {})
scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))
expect(feeProcessingLabel(rows.value[0]!)).toBe('处理失败')
await vi.advanceTimersByTimeAsync(80000)
expect(refresh).toHaveBeenCalledTimes(32)
rows.value = [{ ...pending(), deliveryStatus: 'PUBLISHED', deliveryErrorMessage: '' }]; await nextTick()
expect(feeProcessingLabel(rows.value[0]!)).toBe('已完成')
await vi.advanceTimersByTimeAsync(20000); expect(refresh).toHaveBeenCalledTimes(32)
scope.stop()
})
})
@@ -0,0 +1,28 @@
import { onScopeDispose, watch, type Ref } from 'vue'
import type { OverseasFee } from './api/overseasFees'
/** 跟踪尚未收到回执的事件;失败后自动重试期间继续回查,离开页面即停止。 */
export function useFeeStatusRefresh(rows: Ref<OverseasFee[]>, refresh: () => Promise<void>, enabled: Ref<boolean>) {
let timer: ReturnType<typeof setTimeout> | undefined
let remaining = 90
let stopped = false
const pending = () => rows.value.some(row => row.walletStatus !== 'SETTLED' && (['PENDING', 'PROCESSING'].includes(row.deliveryStatus || '') || row.walletStatus?.endsWith('_PENDING')))
function clear() { if (timer !== undefined) clearTimeout(timer); timer = undefined }
function schedule() {
clear()
if (stopped || !enabled.value || !pending() || remaining <= 0) return
timer = setTimeout(async () => {
timer = undefined
if (stopped || !enabled.value) return
remaining--
await refresh()
schedule()
}, remaining > 60 ? 2000 : 10000)
}
watch([rows, enabled], () => {
if (!pending()) remaining = 90
schedule()
}, { immediate: true })
onScopeDispose(() => { stopped = true; clear() })
return { reset: () => { remaining = 90; clear() } }
}
@@ -44,3 +44,5 @@
- 报价、汇率、车辆、车队报价与出车记录导出统一使用 `../downloads/` 的 `useExportTask`,按当前筛选创建 XLSX 任务;按钮要求原业务导出权限和 `platform:download:list`。出车成本可见性由 File 根据账号权限冻结,不接受页面声明。CSV 仅用于车队报价导入模板。
- 拼车接货简化沿 `FleetCapacityOfferModels.Inquiry.progress` 展示缺项、是否保位、负责人及主要动作,`appointmentCoverage`只消费预约侧真实投影,不以承运成功冒充预约。`CapacityInquiryFulfillment.vue`从原登记按需读取本票履约,不授予销售整车读取权限;来货、核量、排入本车分别保持原写权限。`TripPreparationDialog.vue`和`TripDispatchSelector.vue`通过`index.ts`公开给排车/预约使用;前者提交原子`/fleet/trips/prepare`并衔接容量发布,后者只读取本仓合格候选。新版合同见`fleet-preparation-v1.yaml`及拼车唯一主契约。
- 车辆详情与司机详情通过 `ResourcePhotoPanel.vue` 维护常用照片,接口 `api/resourcePhotos.ts`/`fleet-resource-photos-v1.yaml`;车辆沿vehicle:edit、司机沿driver:upload,读取沿各自list。照片版本独立于档案字段版本,替换保留历史且不改变驾驶证审核;排车缺照片时由服务端复制为当趟证据。只在详情加载元数据、点击预览读取图片,不逐行请求。
@@ -1,5 +1,9 @@
# 海外通用资料原型核对矩阵
## 档案照片复用(2026-10-26)
依据用户确认优化重复上传:车辆概览和司机详情增加常用照片维护,沿既有详情分区与620px居中预览;元数据按详情加载,原图点击才读。车辆编辑权限与司机上传权限分别控制替换。排车选档案后自动带入并保存当趟副本;沿用Admin详情、提示及表单规范。自动行为验证与类型检查单独报告,1920×1080、100%截图待人工确认。
## 散板需求只读展示(2026-10-21)
依据本轮用户确认:仅保留内部余位需求池,人工联系下单;登记与后续拼车直接隐藏,不增加开关,不删除代码。当前范围唯一见[拼车主契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md#当前页面范围2026-10-21),覆盖下文此前开放登记的页面结论。
@@ -367,3 +371,9 @@ S05补充源码核对:实际自有/外配API均经公共解包检查业务码
| OwnedBusinessOperationsEditor、OutsourcedBusinessOperationsEditor | 自有/外配业务详情的居中运营登记弹框 | 表单补齐公共密度控件宽度;目的仓及预约号允许换行,实际到达时间列以受约束宽度占位,防止长业务编号撑宽 | 定向回归通过;待人工1920×1080、100%截图确认 |
依据本轮可用性反馈与本文件原操作矩阵;金额、日期时区、版本及未知结果重试逻辑均未修改。司机、车辆和运维编辑表单已有多列布局与公共底部确认入口,源码复查未据此扩大修改;真实可用性仍需截图确认。
## 装车资料多图(2026-10-26)
依据用户PDA截图标注,五类统一为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字。PDA每类最多5张,可多选追加、逐张更换;Admin排车资料仍沿五行表格展示,每类逐图预览/更换,自有和外配照片弹框展示全部当前图片,油耗保持单张。五类各至少1张与装车确认共同控制自动派送。PDA相关75项、后端47项、Admin照片编辑17项及照片弹框4项通过,定向类型检查通过;扩大到运营布局时另有2项既存关闭行为断言失败,未改动该运营代码。未部署,PDA真机及Admin人工截图待确认。
- 同日双端复核:Admin装车资料补齐多选上传;PDA/Admin保存响应异常时重读服务端当前照片,核对失败保留刷新入口,停止本批后续上传。旧图替换不影响同类其他图,批次已成功部分保留。Admin 6文件32项、PDA 3文件38项及定向类型检查通过;此前2项运营弹框过期断言已按仅busy期间锁定的现行规则修正并通过。后端本轮未改,沿用47项验证结果;未部署、未做真机/人工截图验收。

Some files were not shown because too many files have changed in this diff Show More