Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbb7d4fe24 | ||
|
|
5c162bf6c5 | ||
|
|
8451607f6c | ||
|
|
c148912b73 | ||
|
|
3464f35a01 | ||
|
|
b3ab87f22c | ||
|
|
b9b930936d | ||
|
|
392f94e95c | ||
|
|
4595dfef34 | ||
|
|
0137fd3a4c | ||
|
|
a9dc19a86b | ||
|
|
22242701e4 | ||
|
|
ca7095af4c | ||
|
|
04b099249e | ||
|
|
48d6c31397 | ||
|
|
b06d46b0b3 | ||
|
|
06d814e82f | ||
|
|
acb90007de | ||
|
|
e399f3f76f | ||
|
|
4ee3b541ac | ||
|
|
0ef04804c1 | ||
|
|
cc02f4d907 | ||
|
|
33cb18594e | ||
|
|
b5d8ccdffe | ||
|
|
6580853ad3 | ||
|
|
32c6a0ad7b | ||
|
|
18a78e5508 |
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+64
-8
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' } })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="平台权限资源列表"')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 的数字`
|
||||
}
|
||||
|
||||
@@ -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: '取消后本单不能继续履约,请填写取消原因。',
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user