Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48d6c31397 | ||
|
|
b06d46b0b3 | ||
|
|
06d814e82f | ||
|
|
acb90007de | ||
|
|
e399f3f76f | ||
|
|
4ee3b541ac | ||
|
|
0ef04804c1 | ||
|
|
cc02f4d907 | ||
|
|
33cb18594e | ||
|
|
9de72cbc5c | ||
|
|
b5d8ccdffe | ||
|
|
6580853ad3 | ||
|
|
32c6a0ad7b | ||
|
|
18a78e5508 | ||
|
|
952abd5629 | ||
|
|
7688f859c7 | ||
|
|
227c537099 | ||
|
|
f1dbb63251 | ||
|
|
c853b919c0 | ||
|
|
047cfb3d0b | ||
|
|
ff071c0159 | ||
|
|
9542b71cc7 | ||
|
|
4f766a76bd | ||
|
|
75bb0bf78e |
@@ -146,6 +146,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
icon: PrototypeOverseasBusinessIcon,
|
||||
children: [
|
||||
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
|
||||
{ key: 'overseas-fees', title: '海外费用管理', icon: CircleDollarSign, path: '/overseas-business/fees', permission: 'platform:overseas-fee:list' },
|
||||
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
|
||||
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
|
||||
{ key: 'fleet-capacity-offers', title: '散板需求', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||
|
||||
@@ -21,7 +21,7 @@ function row(id = '9007199254740993', customerId = 'customer-a', key = batch): B
|
||||
function candidate(value = row()): Candidate { const base = { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] }; return { ...base, selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, selectionGroup: { groupKey: base.sourceKey, groupVersion: 'group-1', sources: [{ selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, sourceKey: base.sourceKey, customerId: base.customerId, customerName: base.customerName, sourceBusinessType: null, sourceBusinessNo: null, packages: base.availablePackages }], pallets: [{ warehousePalletId: base.selection.warehousePalletId, palletNo: base.palletNo, sourceKeys: [base.sourceKey], packageCount: base.availablePackages }], complete: true, blockReasons: [] } } }
|
||||
function detail(no = 'DS/001', version = 1): DispatchDetail {
|
||||
const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }
|
||||
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
|
||||
return { publishLooseboardDemand: false, dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], exceptions: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
|
||||
}
|
||||
function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } }
|
||||
let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft
|
||||
@@ -89,7 +89,7 @@ it('reads remaining physical cargo only after an explicit choice while retaining
|
||||
})
|
||||
it('creates the whole dispatch with native source versions and exact batch allocations', async () => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(createDispatch).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null }))
|
||||
expect(createDispatch).toHaveBeenCalledWith(expect.objectContaining({ publishLooseboardDemand: false, warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null }))
|
||||
expect(wrapper.text()).toContain('DS/001'); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true)
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -124,7 +124,7 @@ async function save() {
|
||||
ensureOwner()
|
||||
if (!writable.value || error.value || createdNo.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
|
||||
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
|
||||
const body = { publishLooseboardDemand: true, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(chosen.value.map(row => row.native!)), batchAllocations: chosen.value.flatMap(row => row.allocations) }
|
||||
const body = { publishLooseboardDemand: false, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(chosen.value.map(row => row.native!)), batchAllocations: chosen.value.flatMap(row => row.allocations) }
|
||||
const proof = await createDispatch(body); ensureOwner()
|
||||
validateCommandResult(proof, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: body.idempotencyKey } })
|
||||
createdNo.value = proof.dispatchNo; selected.value = []; baseline.value = '[]'; emit('updated')
|
||||
|
||||
@@ -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 :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>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import SmartLoadingDialog from '../components/SmartLoadingDialog.vue'
|
||||
import { loadSmartLoadingPlanCoLoadGroups } from '../smartLoadingPlanGroups'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
import {
|
||||
assignPendingReviewSmartLoadingOrder,
|
||||
@@ -210,9 +211,9 @@ describe('GlobalSmartLoadingView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
|
||||
it('uses one-line shared text clamps for block reasons and assigned-count summaries', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
|
||||
expect(source).toContain('<LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" />')
|
||||
expect(source).toContain('<LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
|
||||
expect(source).toContain("row.unitType === 'SEALED_PALLET'")
|
||||
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
|
||||
@@ -608,9 +609,11 @@ describe('GlobalSmartLoadingView', () => {
|
||||
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('CLG-2093539779795795969 · 2 票')
|
||||
expect(wrapper.text()).toContain('3 个入仓片段')
|
||||
expect(wrapper.text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B')
|
||||
const candidatePane = wrapper.get('.candidate-pane')
|
||||
expect(candidatePane.get('.candidate-group').text()).toBe('合柜报关 · 2票整组不可拆')
|
||||
expect(candidatePane.text()).toContain('NYEXX2608290051、NYEXX2608290051-B、NYEXX2608290052')
|
||||
expect(candidatePane.text()).not.toContain('CLG-2093539779795795969')
|
||||
expect(candidatePane.text()).not.toContain('ORD-201')
|
||||
const buttons = wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入')
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
@@ -623,6 +626,8 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(contextRows[1]!.findAll('td').map(cell => cell.text())).toEqual(['义乌仓', '美森正班'])
|
||||
await assignmentDialog().get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(assignmentDialog().get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('NYEXX2608290051-B')
|
||||
expect(assignmentDialog().get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B')
|
||||
expect(assignmentDialog().text()).toContain('3 个入仓片段')
|
||||
expect(document.body.textContent).not.toContain('本次分配箱数')
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
@@ -684,7 +689,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('CLG-9900 · 3 票')
|
||||
expect(wrapper.text()).toContain('合柜报关 · 3票')
|
||||
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
|
||||
const button = wrapper.findAll('.el-table__body-wrapper button').find((item) => item.text() === '加入')!
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
@@ -749,16 +754,36 @@ describe('GlobalSmartLoadingView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses one loading-rule column and expandable group details in both workbenches', () => {
|
||||
it('uses one loading-rule column in both workbenches and keeps global list summaries compact', () => {
|
||||
for (const page of ['GlobalSmartLoadingView.vue', 'SmartLoadingWorkbenchView.vue']) {
|
||||
const source = readFileSync(resolve(process.cwd(), `src/modules/container-loading/pages/${page}`), 'utf8')
|
||||
expect(source.match(/label: '装柜规则'/g)).toHaveLength(2)
|
||||
expect(source).not.toContain("label: '分配单位'")
|
||||
expect(source).not.toContain('<template #unitType=')
|
||||
expect(source.match(/<summary>整组加入 · 不可拆<\/summary>/g)).toHaveLength(2)
|
||||
expect(source.match(/'整托加入 · 不可拆' : '按箱加入'/g)).toHaveLength(2)
|
||||
expect(source).toContain("row.memberOrderSummaries.join(';')")
|
||||
}
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
expect(source.match(/slotName: 'coLoadGroup', showOverflowTooltip: false/g)).toHaveLength(2)
|
||||
expect(source).not.toContain("row.memberOrderSummaries.join(';')")
|
||||
})
|
||||
|
||||
it('shows compact locked plan groups with all inbound codes and fixed package totals', async () => {
|
||||
const group = { groupId: 'G-1', groupNo: 'CLG-1', lockedPlanId: plan.id, memberOrderCount: 2, inboundFragmentCount: 3, fullPackageCount: 18 }
|
||||
vi.mocked(loadSmartLoadingPlanCoLoadGroups).mockReturnValueOnce(new Map(['2', '3', '4'].map(id => [id, group])))
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
items: ['2', '3', '4'].map((inboundId, index) => ({ ...plan.items[0]!, id: `item-${inboundId}`, inboundId, inboundCode: `IN-${inboundId}`, orderId: index < 2 ? 'order-1' : 'order-2' })),
|
||||
})
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const pane = wrapper.get('.plan-pane')
|
||||
expect(pane.get('.candidate-group').text()).toBe('合柜报关 · 2票整组不可拆 · 已锁定')
|
||||
expect(pane.text()).toContain('IN-2、IN-3、IN-4')
|
||||
expect(pane.text()).toContain('18 箱(整组固定)')
|
||||
expect(pane.text()).not.toContain('CLG-1')
|
||||
expect(pane.text()).not.toContain('ORD-1')
|
||||
expect(pane.findAll('.el-table__body-wrapper tr')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('searches any shared-pallet inbound, retains its complete composition across pages, and resets the search', async () => {
|
||||
|
||||
@@ -157,7 +157,7 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
|
||||
const planPalletMembersByPalletId = computed(() => new Map(
|
||||
(plan.value?.palletMembers || []).map((entry) => [entry.palletId, entry.members]),
|
||||
))
|
||||
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[]; memberOrderSummaries: string[] }
|
||||
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[] }
|
||||
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
|
||||
const result: SmartLoadingPlanRow[] = []
|
||||
@@ -165,7 +165,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
for (const row of inboundRows) {
|
||||
const group = planGroupsByInboundId.value.get(row.inboundId)
|
||||
if (!group) {
|
||||
result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
|
||||
result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'] })
|
||||
continue
|
||||
}
|
||||
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
|
||||
@@ -191,7 +191,6 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
coLoadGroup: group,
|
||||
palletMembers: [],
|
||||
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
|
||||
memberOrderSummaries: memberOrderSummaries(rows),
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -247,7 +246,7 @@ const candidateColumns: DataTableColumn[] = [
|
||||
{ key: 'totalGrossWeightKg', label: '重量', width: 100, slotName: 'candidateTotalWeight' },
|
||||
{ key: 'totalVolumeCbm', label: '体积', width: 100, slotName: 'candidateTotalVolume' },
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
|
||||
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup', showOverflowTooltip: false },
|
||||
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 90, slotName: 'unitGrossWeight' },
|
||||
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 90, slotName: 'averageUnitVolume' },
|
||||
{ key: 'estimatedVolumeCbm', label: '预估体积', width: 100, slotName: 'estimatedVolume' },
|
||||
@@ -260,7 +259,7 @@ const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'grossWeightKg', label: '毛重', width: 90, slotName: 'weight' },
|
||||
{ key: 'volumeCbm', label: '体积', width: 90, slotName: 'volume' },
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
|
||||
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup', showOverflowTooltip: false },
|
||||
]
|
||||
|
||||
function formatNumber(value: number, digits = 3) {
|
||||
@@ -753,9 +752,16 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
@click="openAssign(row)"
|
||||
>加入</el-button>
|
||||
</template>
|
||||
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
|
||||
<template #candidateInbounds="{ row }"><span v-if="row.coLoadGroupId">{{ row.memberInboundCodes.join('、') }}</span><SmartLoadingCandidateInbounds v-else :candidate="row as SmartLoadingCandidate" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroupId" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
|
||||
<template #coLoadGroup="{ row }">
|
||||
<div v-if="row.coLoadGroupId" class="candidate-group">
|
||||
<el-tag size="small" type="warning">合柜报关 · {{ row.memberOrderCount }}票</el-tag>
|
||||
<span>整组不可拆{{ row.locked ? ' · 已锁定' : '' }}</span>
|
||||
<LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" />
|
||||
</div>
|
||||
<LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" />
|
||||
</template>
|
||||
<template #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template>
|
||||
<template #unitGrossWeight="{ row }">{{ formatMeasurement(row.unitGrossWeightKg, 'kg', 1) }}</template>
|
||||
<template #candidateTotalWeight="{ row }">{{ formatMeasurement(row.totalGrossWeightKg, 'kg', 1) }}</template>
|
||||
@@ -828,9 +834,15 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</div>
|
||||
<div class="workbench-table"><DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="danger" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
|
||||
<template #planInbounds="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
|
||||
<template #planInbounds="{ row }"><span v-if="row.coLoadGroup">{{ row.memberInboundCodes.join('、') }}</span><SmartLoadingCandidateInbounds v-else :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
|
||||
<template #coLoadGroup="{ row }">
|
||||
<div v-if="row.coLoadGroup" class="candidate-group">
|
||||
<el-tag size="small" type="warning">合柜报关 · {{ row.coLoadGroup.memberOrderCount }}票</el-tag>
|
||||
<span>整组不可拆 · {{ row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定' }}</span>
|
||||
</div>
|
||||
<LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" />
|
||||
</template>
|
||||
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
||||
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
@@ -114,6 +121,7 @@ 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: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' },
|
||||
@@ -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 || '')
|
||||
@@ -468,7 +521,7 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
<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>
|
||||
<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 v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false">
|
||||
@@ -483,7 +536,7 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
</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"><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 #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 #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 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 +544,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="当前账户资料">
|
||||
|
||||
@@ -69,6 +69,7 @@ describe('InboundTaskList measurement scope', () => {
|
||||
expect(source).toContain(':show-actions="false"')
|
||||
expect(source).toContain('<template #footer="{ close }">')
|
||||
expect(source).toContain('reviewPanelRef?.confirmApprove()')
|
||||
expect(source).not.toContain('修改重量')
|
||||
})
|
||||
|
||||
it('loads only active customers and exposes a searchable name-only select', () => {
|
||||
|
||||
@@ -469,7 +469,6 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
<div class="review-dialog-footer">
|
||||
<el-button :disabled="reviewNavigationLoading" @click="close">关闭</el-button>
|
||||
<template v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview">
|
||||
<el-button type="primary" plain @click="reviewPanelRef?.enableWeightCorrection()">修改重量</el-button>
|
||||
<el-button type="primary" :loading="reviewPanelRef?.actionLoading === 'approve' || reviewNavigationLoading" :disabled="reviewApproved" @click="reviewPanelRef?.confirmApprove()">{{ reviewApproved ? '审核已通过' : reviewPanelRef?.approvalButtonLabel }}</el-button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -62,13 +62,19 @@ describe('MeasurementReviewPanel', () => {
|
||||
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
})
|
||||
|
||||
it('keeps customer context and pending actions in one sticky toolbar while reviewing long content', async () => {
|
||||
it('keeps context in a scrolling toolbar and moves weight actions and totals to the weight details', async () => {
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const toolbar = wrapper.get('.review-toolbar')
|
||||
expect(toolbar.text()).toContain('待审核')
|
||||
expect(toolbar.text()).toContain('修改重量')
|
||||
expect(toolbar.text()).not.toContain('修改重量')
|
||||
const weightHeading = wrapper.get('.review-weight-items header')
|
||||
expect(weightHeading.text()).toContain('修改重量')
|
||||
expect(weightHeading.text()).toContain('实测毛重 120 kg')
|
||||
expect(weightHeading.text()).toContain('货物重量 110 kg')
|
||||
expect(weightHeading.find('.lucide-scale').exists()).toBe(true)
|
||||
expect(weightHeading.find('.lucide-pencil').exists()).toBe(true)
|
||||
expect(toolbar.findAll('button').map(button => button.text())).toContain('通过')
|
||||
expect(toolbar.text()).toContain('刷新')
|
||||
expect(toolbar.text()).toContain('客户:柏悦客户')
|
||||
@@ -83,7 +89,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
expect(wrapper.text()).not.toContain('审核历史')
|
||||
expect(listMeasurementReviewHistory).not.toHaveBeenCalled()
|
||||
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
|
||||
expect(source).toContain('position: sticky')
|
||||
expect(source).not.toContain('position: sticky')
|
||||
expect(source).toContain('review-toolbar__context')
|
||||
expect(source.indexOf('class="review-toolbar"')).toBeLessThan(source.indexOf('<WeightOcrPanel'))
|
||||
expect(source).not.toContain('reasonDialog')
|
||||
@@ -193,7 +199,6 @@ describe('MeasurementReviewPanel', () => {
|
||||
|
||||
const weightPanel = wrapper.getComponent({ name: 'WeightOcrPanel' })
|
||||
expect(weightPanel.props('reviewMode')).toBe(true)
|
||||
expect(weightPanel.props('tableMode')).toBe(true)
|
||||
expect(weightPanel.props('correctionMode')).toBe(false)
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('修改重量'))?.trigger('click')
|
||||
expect(weightPanel.props('correctionMode')).toBe(true)
|
||||
@@ -229,7 +234,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
expect(wrapper.find('.review-toolbar__actions').exists()).toBe(false)
|
||||
expect(wrapper.get('.review-toolbar__context').text()).toContain('客户:柏悦客户')
|
||||
expect(wrapper.vm.approvalButtonLabel).toBe('通过')
|
||||
wrapper.vm.enableWeightCorrection()
|
||||
await wrapper.get('.review-weight-summary button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WeightOcrPanel' }).props('correctionMode')).toBe(true)
|
||||
await wrapper.vm.confirmApprove()
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Check, Pencil, RotateCcw } from '@lucide/vue'
|
||||
import { Check, Pencil, RotateCcw, Ruler, Scale } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -13,6 +13,7 @@ import { downloadInboundMeasurementImage } from '../measurement/api'
|
||||
import MeasurementEvidenceThumbnail from '../measurement/components/MeasurementEvidenceThumbnail.vue'
|
||||
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
||||
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
|
||||
import type { WeightResultResource } from '../weight/types'
|
||||
import { approveMeasurement, getMeasurementReviewDetail } from './api'
|
||||
import type { MeasurementReviewDetail } from './types'
|
||||
|
||||
@@ -38,6 +39,7 @@ const loading = ref(false)
|
||||
const actionLoading = ref('')
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const correctionMode = ref(false)
|
||||
const weightPanelRef = ref<InstanceType<typeof WeightOcrPanel> | null>(null)
|
||||
const requestSerial = ref(0)
|
||||
const imagePreviewOpen = ref(false)
|
||||
const imagePreviewTitle = ref('测量图片')
|
||||
@@ -125,7 +127,7 @@ async function loadReview() {
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await loadReview()
|
||||
await Promise.all([loadReview(), weightPanelRef.value?.reload()])
|
||||
}
|
||||
|
||||
async function handleWeightChanged() {
|
||||
@@ -175,7 +177,7 @@ onMounted(() => {
|
||||
})
|
||||
onBeforeUnmount(releaseImagePreview)
|
||||
|
||||
defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPending, canReview, actionLoading })
|
||||
defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, actionLoading })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -183,7 +185,6 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
<h2 id="measurement-review-title" class="sr-only">测量审核内容</h2>
|
||||
<div class="review-toolbar" aria-label="测量审核操作">
|
||||
<div v-if="showActions" class="review-toolbar__actions">
|
||||
<el-button v-if="isPending && canReview" type="primary" plain @click="enableWeightCorrection"><Pencil :size="16" />修改重量</el-button>
|
||||
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve' || postApproveLoading" :disabled="approvalCompleted" @click="confirmApprove"><Check :size="16" />{{ approvalCompleted ? '审核已通过' : approvalButtonLabel }}</el-button>
|
||||
</div>
|
||||
<div class="review-toolbar__status">
|
||||
@@ -220,18 +221,13 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
|
||||
<section class="review-items density-dialog-section" data-tone="source" aria-labelledby="review-items-title">
|
||||
<header class="review-section-heading density-dialog-section__title">
|
||||
<div>
|
||||
<h3 id="review-items-title">箱规测量明细</h3>
|
||||
</div>
|
||||
<h3 id="review-items-title" class="review-detail-heading"><Ruler :size="18" aria-hidden="true" />箱规测量明细</h3>
|
||||
<span>{{ measurementCount }} 条</span>
|
||||
</header>
|
||||
<div v-if="detail.items.length" class="review-table-scroll">
|
||||
<el-table :data="detail.items" border stripe table-layout="fixed" class="review-table">
|
||||
<el-table-column type="index" label="#" width="44" align="center" />
|
||||
<el-table-column label="箱号" width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="测量图片" width="98" align="center">
|
||||
<el-table-column label="测量图片" width="112" align="center">
|
||||
<template #default="{ row }">
|
||||
<MeasurementEvidenceThumbnail
|
||||
:customer-id="customerId"
|
||||
@@ -243,6 +239,9 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="箱号" width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="尺寸 长×宽×高(cm)" min-width="200" align="center">
|
||||
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
|
||||
</el-table-column>
|
||||
@@ -258,7 +257,7 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
<el-table-column label="体积重(kg)" width="96" align="right">
|
||||
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="测量时间" width="190" class-name="review-time-cell">
|
||||
<el-table-column label="测量时间" width="220" class-name="review-time-cell">
|
||||
<template #default="{ row }">{{ formatDate(row.deviceMeasuredAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -267,34 +266,45 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
</section>
|
||||
|
||||
<section class="review-items review-weight-items density-dialog-section" data-tone="source" aria-labelledby="review-weight-title">
|
||||
<header class="review-section-heading density-dialog-section__title"><h3 id="review-weight-title">称重明细</h3><span>{{ weightCount }} 条</span></header>
|
||||
<div v-if="weightCount" class="review-table-scroll">
|
||||
<el-table :data="detail.weightResults" border stripe table-layout="fixed" class="review-table review-weight-table">
|
||||
<el-table-column type="index" label="#" width="44" align="center" />
|
||||
<el-table-column label="称重图片" width="112" align="center"><template #default="{ row }"><MeasurementEvidenceThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`${row.palletCode || '托盘'}称重图片`" empty-text="无图片" @open="openImagePreview(row.fileId, row.palletCode || '托盘')" /></template></el-table-column>
|
||||
<el-table-column prop="palletCode" label="托盘编码" min-width="120" />
|
||||
<el-table-column prop="palletType" label="托盘类型" min-width="120" />
|
||||
<el-table-column label="库位" min-width="130"><template #default="{ row }"><span class="review-location">{{ row.locationCode || '—' }}<template v-if="row.locationCode"> · {{ row.slotStatus === 'BOUND' ? '待上架' : row.slotStatus || '待上架' }}</template></span></template></el-table-column>
|
||||
<el-table-column label="毛重(KG)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.grossWeightKg, 3) }}</template></el-table-column>
|
||||
<el-table-column label="托盘皮重(kg)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.palletTareWeightKg, 3) }}</template></el-table-column>
|
||||
<el-table-column label="货物重量(kg)" min-width="120" align="right"><template #default="{ row }"><strong class="review-weight-value">{{ formatNumber(row.netWeightKg, 3) }}</strong></template></el-table-column>
|
||||
<el-table-column label="称重时间" min-width="150"><template #default="{ row }">{{ formatDate(row.confirmedAt || row.adjustedAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-empty v-else description="暂无称重明细" :image-size="48" />
|
||||
<header class="review-section-heading density-dialog-section__title">
|
||||
<h3 id="review-weight-title" class="review-detail-heading"><Scale :size="18" aria-hidden="true" />称重明细</h3>
|
||||
<div class="review-weight-summary">
|
||||
<span>实测毛重 <strong>{{ formatNumber(detail.actualGrossWeightKg) }}</strong> kg</span>
|
||||
<span>货物重量 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span>
|
||||
<el-button v-if="isPending && canReview" type="primary" plain @click="enableWeightCorrection"><Pencil :size="16" />修改重量</el-button>
|
||||
<span>{{ weightCount }} 条</span>
|
||||
</div>
|
||||
</header>
|
||||
<WeightOcrPanel
|
||||
ref="weightPanelRef"
|
||||
review-mode
|
||||
:correction-mode="correctionMode && isPending && canReview"
|
||||
:customer-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleWeightChanged"
|
||||
>
|
||||
<template #heading><span id="weight-ocr-title" class="sr-only">称重图片预览与修正</span></template>
|
||||
<template #results="{ rows, openPreview, canCorrect, busy }">
|
||||
<div v-if="rows.length" class="review-table-scroll">
|
||||
<el-table :data="rows" border stripe table-layout="fixed" class="review-table review-weight-table">
|
||||
<el-table-column v-if="canCorrect" label="操作" width="100">
|
||||
<template #default="{ row, $index }"><el-button v-if="row.recognitionStatus === 'CONFIRMED'" link type="primary" :aria-label="`修改重量 ${$index + 1}`" :disabled="busy" @click="openPreview(row as WeightResultResource)">修改重量</el-button><span v-else>—</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column type="index" label="#" width="44" align="center" />
|
||||
<el-table-column label="称重图片" width="112" align="center"><template #default="{ row }"><MeasurementEvidenceThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`${row.palletCode || '托盘'}称重图片`" empty-text="无图片" @open="openPreview(row as WeightResultResource)" /></template></el-table-column>
|
||||
<el-table-column prop="palletCode" label="托盘编码" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="palletType" label="托盘类型" min-width="120" />
|
||||
<el-table-column label="库位" min-width="130"><template #default="{ row }"><span class="review-location">{{ row.locationCode || '—' }}<template v-if="row.locationCode"> · {{ row.slotStatus === 'BOUND' ? '待上架' : row.slotStatus || '待上架' }}</template></span></template></el-table-column>
|
||||
<el-table-column label="毛重(KG)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.grossWeightKg, 3) }}</template></el-table-column>
|
||||
<el-table-column label="托盘皮重(kg)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.palletTareWeightKg, 3) }}</template></el-table-column>
|
||||
<el-table-column label="货物重量(kg)" min-width="120" align="right"><template #default="{ row }"><strong class="review-weight-value">{{ formatNumber(row.netWeightKg, 3) }}</strong></template></el-table-column>
|
||||
<el-table-column label="称重时间" width="220" class-name="review-time-cell"><template #default="{ row }">{{ formatDate(row.confirmedAt || row.adjustedAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-empty v-else-if="!busy" description="暂无称重明细" :image-size="48" />
|
||||
</template>
|
||||
</WeightOcrPanel>
|
||||
</section>
|
||||
|
||||
<WeightOcrPanel
|
||||
class="density-dialog-section"
|
||||
data-tone="source"
|
||||
review-mode
|
||||
table-mode
|
||||
:correction-mode="correctionMode"
|
||||
:customer-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleWeightChanged"
|
||||
:class="{ 'review-weight-editor--hidden': !correctionMode }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="imagePreviewOpen" :title="imagePreviewTitle" width="min(760px, 92vw)" fullscreenable :show-footer="false" :close-on-click-modal="true" destroy-on-close append-to-body @closed="releaseImagePreview">
|
||||
@@ -313,7 +323,7 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
|
||||
<style scoped>
|
||||
.review-panel { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--admin-space-5, 12px); color: var(--workbench-text); }
|
||||
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); position: sticky; top: 0; z-index: 3; flex-wrap: wrap; padding: var(--admin-space-3, 8px) 0; border-bottom: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
|
||||
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); flex-wrap: wrap; padding: var(--admin-space-3, 8px) 0; border-bottom: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
|
||||
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }
|
||||
.review-toolbar__status > span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.review-toolbar__actions { justify-content: flex-end; }
|
||||
@@ -325,6 +335,8 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
.review-section-heading > div { min-width: 0; }
|
||||
.review-section-heading h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section, 14px); line-height: 1.35; }
|
||||
.review-section-heading > span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); white-space: nowrap; }
|
||||
.review-items > .review-section-heading { min-height: var(--admin-control-height, 32px); }
|
||||
.review-weight-items :deep(.weight-list) { margin-top: 0; }
|
||||
.review-comparison { table-layout: fixed; }
|
||||
.review-extra-facts { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-5, 12px); margin-top: var(--admin-space-3, 8px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.review-extra-facts strong { color: var(--workbench-text); }
|
||||
@@ -334,7 +346,10 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
.review-table :deep(.measurement-evidence-thumb) { min-width: 72px; border-radius: var(--admin-radius, 4px); }
|
||||
.review-weight-value { color: var(--el-color-success-dark-2); font-weight: 600; }
|
||||
.review-location { white-space: nowrap; }
|
||||
.review-weight-editor--hidden { display: none; }
|
||||
.review-detail-heading, .review-weight-summary { display: flex; align-items: center; gap: var(--admin-space-5, 12px); }
|
||||
.review-detail-heading > svg { flex-shrink: 0; }
|
||||
.review-weight-summary { flex-wrap: wrap; color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.review-weight-summary strong { color: var(--el-color-success-dark-2); }
|
||||
.review-image-preview { display: grid; min-height: 240px; place-items: center; }
|
||||
.review-image-preview img { display: block; width: auto; max-width: 100%; max-height: 70vh; object-fit: contain; }
|
||||
.review-image-preview__error { display: grid; justify-items: center; gap: var(--admin-space-3, 8px); color: var(--el-color-danger); }
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||
import WeightOcrPanel from './WeightOcrPanel.vue'
|
||||
import InboundMeasurementDialog from '../../measurement/InboundMeasurementDialog.vue'
|
||||
import MeasurementReviewPanel from '../../review/MeasurementReviewPanel.vue'
|
||||
import { getMeasurementReviewDetail } from '../../review/api'
|
||||
|
||||
vi.mock('../../review/api', () => ({ getMeasurementReviewDetail: vi.fn(), approveMeasurement: vi.fn() }))
|
||||
|
||||
vi.mock('../api/weightOcr', async () => {
|
||||
const actual = await vi.importActual<typeof import('../api/weightOcr')>('../api/weightOcr')
|
||||
@@ -139,7 +143,7 @@ function mountPanel(permissions = allWeightPermissions, props: Record<string, un
|
||||
})
|
||||
}
|
||||
|
||||
async function openWeightDialog(wrapper: VueWrapper) {
|
||||
async function openWeightDialog(wrapper: Pick<VueWrapper, 'get'>) {
|
||||
await wrapper.get('button[aria-label="查看称重图片 1"]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
@@ -507,6 +511,58 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
expect(wrapper.text()).toContain('1747.500 KG')
|
||||
})
|
||||
|
||||
it('corrects a weight in the single review details table and refreshes totals without adding an image section', async () => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue(confirmedReviewWorkspace)
|
||||
const reviewDetail = {
|
||||
inboundId: '20001', inboundCode: 'RK-20001', companyName: '测试客户', channelCode: 'SEA', channelName: '海运',
|
||||
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: null,
|
||||
reviewReason: null, reviewerId: null, reviewerName: null, measurementId: '70001', sourceType: 'DEVICE',
|
||||
measurementStatus: 'SUBMITTED', inboundVersion: 1, version: 1, originalDocumentNo: null,
|
||||
originalGrossWeightKg: 100, originalVolumeCbm: 1, originalPackageCount: 1, actualPackageCount: 1,
|
||||
actualGrossWeightKg: 120, actualNetWeightKg: 108, actualVolumeCbm: 1, volumeWeightKg: 167,
|
||||
actualGoodsName: null, items: [], weightResults: confirmedReviewWorkspace.weightResults,
|
||||
}
|
||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(reviewDetail)
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = [...allWeightPermissions, 'firstleg:inbound:measurement-review']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(MeasurementReviewPanel, {
|
||||
attachTo: document.body,
|
||||
props: { customerId: '10001', inboundId: '20001', showActions: false },
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.review-weight-table')).toHaveLength(1)
|
||||
expect(wrapper.find('.weight-data-table').exists()).toBe(false)
|
||||
expect(wrapper.find('.weight-heading').exists()).toBe(false)
|
||||
expect(wrapper.find('button[aria-label="修改重量 1"]').exists()).toBe(false)
|
||||
await wrapper.get('.review-weight-summary button').trigger('click')
|
||||
await wrapper.get('button[aria-label="修改重量 1"]').trigger('click')
|
||||
await flushPromises()
|
||||
await dialogWeightInput().setValue('125')
|
||||
await new DOMWrapper(document.body.querySelector<HTMLTextAreaElement>('#weight-correction-reason')!).setValue('复核磅秤读数')
|
||||
const updatedWeight = { ...confirmedReviewWorkspace.weightResults[0], grossWeightKg: 125, netWeightKg: 113, version: 2 }
|
||||
vi.mocked(confirmWeightResult).mockResolvedValue(updatedWeight)
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...confirmedReviewWorkspace, actualGrossWeightKg: 125, actualNetWeightKg: 113, weightResults: [updatedWeight] })
|
||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...reviewDetail, actualGrossWeightKg: 125, actualNetWeightKg: 113, inboundVersion: 2 })
|
||||
await dialogConfirmButton('保存重量修正').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
version: 1, adjustedWeightValue: 125, adjustedWeightUnit: 'KG', adjustmentReason: '复核磅秤读数',
|
||||
})
|
||||
expect(wrapper.get('.review-weight-summary').text()).toContain('实测毛重 125 kg')
|
||||
expect(wrapper.get('.review-weight-summary').text()).toContain('货物重量 113 kg')
|
||||
expect(wrapper.get('.review-weight-table').text()).toContain('125')
|
||||
expect(wrapper.findAll('.review-weight-table')).toHaveLength(1)
|
||||
const workspaceCalls = vi.mocked(getWeightWorkspace).mock.calls.length
|
||||
await wrapper.get('.review-refresh').trigger('click')
|
||||
await flushPromises()
|
||||
expect(getWeightWorkspace).toHaveBeenCalledTimes(workspaceCalls + 1)
|
||||
})
|
||||
|
||||
it('opens the explicit table correction action and atomically confirms an approved weight during measurement review', async () => {
|
||||
const confirmed = {
|
||||
...pendingWeight,
|
||||
@@ -556,6 +612,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
{ permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:edit')), status: 'MEASURE_PENDING', correctionMode: true },
|
||||
{ permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:confirm')), status: 'MEASURE_PENDING', correctionMode: true },
|
||||
{ permissions: allWeightPermissions, status: 'WAIT_SPLIT', correctionMode: true },
|
||||
{ permissions: allWeightPermissions, status: 'MEASURE_REJECTED', correctionMode: true },
|
||||
{ permissions: allWeightPermissions, status: 'MEASURE_PENDING', correctionMode: false },
|
||||
])('keeps table correction unavailable for missing permissions or inactive review: %j', async ({ permissions, status, correctionMode }) => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...confirmedReviewWorkspace, processStatus: status })
|
||||
|
||||
@@ -109,9 +109,9 @@ const previewConfirmDisabled = computed(() => {
|
||||
|| Boolean(activeAction.value)
|
||||
})
|
||||
const previewCanConfirm = computed(() => Boolean(previewResult.value && canConfirm.value
|
||||
&& (editable.value || (reviewCorrectionEnabled.value && canEdit.value))))
|
||||
&& ((!props.reviewMode && editable.value) || (reviewCorrectionEnabled.value && canEdit.value))))
|
||||
const previewInputEditable = computed(() => Boolean(previewResult.value && canEdit.value && canConfirm.value
|
||||
&& (editable.value || reviewCorrectionEnabled.value)))
|
||||
&& ((!props.reviewMode && editable.value) || reviewCorrectionEnabled.value)))
|
||||
const previewConfirmText = computed(() => previewResult.value?.recognitionStatus === 'CONFIRMED'
|
||||
? '保存重量修正'
|
||||
: '确认重量')
|
||||
@@ -495,6 +495,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
|
||||
<template>
|
||||
<section class="weight-workspace admin-workbench" aria-labelledby="weight-ocr-title">
|
||||
<slot name="heading">
|
||||
<header class="weight-heading">
|
||||
<div>
|
||||
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
|
||||
@@ -535,6 +536,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
><ImagePlus :size="16" />上传称重图片</el-button>
|
||||
</div>
|
||||
</header>
|
||||
</slot>
|
||||
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
|
||||
当前账号缺少入库测量查看权限。
|
||||
@@ -565,7 +567,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
<template #details><ApiErrorMeta :details="palletError" /></template>
|
||||
</LoadErrorState>
|
||||
<el-alert
|
||||
v-else-if="!palletLoading && palletOptions.length === 0 && editable && canEdit"
|
||||
v-else-if="!reviewMode && !palletLoading && palletOptions.length === 0 && editable && canEdit"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
@@ -575,6 +577,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
<el-button size="small" @click="reloadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
|
||||
</el-alert>
|
||||
<div v-loading="loading" class="weight-list" aria-live="polite">
|
||||
<slot name="results" :rows="rows" :open-preview="openPreview" :can-correct="reviewCorrectionEnabled && canEdit && canConfirm" :busy="loading || Boolean(activeAction)">
|
||||
<div v-if="!loading && rows.length === 0" class="weight-empty">
|
||||
<strong>尚未上传称重图片</strong>
|
||||
</div>
|
||||
@@ -648,6 +651,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
## 关键边界
|
||||
|
||||
- 预装详情进入后先调 `/loading-plans/{planId}/lifecycle`,再按返回状态决定可操作项;不自行推断生命周期阶段。
|
||||
- 托盘码格式为“真实柜号-FBA 地址内部编号-三位序号”,清单只取服务端按 FBA 仓分组的冻结结果,前端不生成、不改码、不重新编号;一件代发托盘不进入该清单。
|
||||
- 托盘码格式为“柜次号-FBA 地址内部编号-三位序号”,清单只取服务端按 FBA 仓分组的冻结结果,前端不生成、不改码、不重新编号;一件代发托盘不进入该清单。
|
||||
- 确认预装和创建 PDA 任务前必须补齐真实集装箱号;历史上已建缺号任务可在预装详情首次补录,保存后恢复 PDA 可进入状态。
|
||||
- 柜级公共费用只按服务端柜内客户/订单事实分摊,创建、审核、驳回、调整、作废均整组原子,金额必须守恒;待确认时禁止调整或重复作废。
|
||||
- 独立清单详情与货柜详情页签共用同一套清单 API 与详情组件;改展示或打印时同步检查 `container-loading` 的装柜清单页签。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| 订舱列表、放舱状态与关联候选 | `pages/OceanBookingsView.vue`;`ocean-bookings` | `api/platformOceanBookings.ts`;`firstleg/oceanbooking/` |
|
||||
| 新增订舱(登记线下已确认订舱) | `pages/OceanBookingDetailView.vue`;`ocean-bookings/new` | `api/platformOceanBookings.ts` |
|
||||
| 订舱详情、资料维护与版本 | `pages/OceanBookingDetailView.vue`;`ocean-bookings/:bookingId` | `api/platformOceanBookings.ts` |
|
||||
| 订舱资料面板与字段边界 | `components/OceanBookingDocumentsPanel.vue` | `firstleg/oceanbooking/`;附件归 `file/` |
|
||||
| 订舱附件组件(详情页已移除入口) | `components/OceanBookingDocumentsPanel.vue` | `firstleg/oceanbooking/`;附件归 `file/` |
|
||||
| 订舱资料打印 | `components/OceanBookingPrintDialog.vue` | `print/` 单据模板 |
|
||||
| 柜型需求与数量换算 | `containerRequirements.ts` | `containerCount` 为柜型需求 `quantity` 总和 |
|
||||
| 订舱代理候选 | `bookingAgents.ts` | 公共供应商列表,仅 `ACTIVE` 且类别/范围匹配 |
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
业务依据为用户提供的《订舱信息页面说明.docx》及页面截图;本轮确认:放舱后允许单独维护预计到港及补实货量、客服手工录入、保留现有提单确认件,正式提单从外部附件接收。样式使用 `docs/Admin高密度界面规范.md` 的详情与基本信息、蓝白主题及既有公共密度。用户后续提供现页与产品稿对比,确认现页缺少层次、重点且间距过大;订舱页以该产品稿为局部视觉依据:紧凑页头、蓝色圆点连线流程、编号区块、细线小标题、浅底三方卡片及右下主打印按钮。可编辑输入、下拉和文本域使用白底、清晰边框与文字,悬停/聚焦使用蓝色反馈;灰底和禁止光标仅用于真实禁用状态,校验错误仍保留组件错误色。
|
||||
|
||||
订舱详情按用户 2026-10-23 截图确认移除“订舱资料”模块及两类文件上传入口,放舱不再要求上传订舱确认文件或提单文件;其他业务资料校验保留。修改后页面待人工截图确认。
|
||||
|
||||
流程条根据已保存订舱、当前未保存修改、放舱状态及实货量推进:当前步骤蓝底,已通过阶段勾选,待处理灰色。第一步的“已保存”不代替放舱完整性校验;打印节点仅在本次页面成功生成对应数据的 PDF 预览后显示“已预览”,失败或只点击按钮不产生预览事实,也不声称实际打印完成。预览标记随本次页面保留;刷新后按服务端保存/放舱/实值恢复进度,已放舱时托运单阶段保持通过,提示“可重新预览 / 打印”,避免前面灰色、后面完成的断开。放舱前托运单数据改变须重新预览;提单确认件始终要求已保存实值和对应 PDF 成功预览后才完成。
|
||||
|
||||
| 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 页面核对 |
|
||||
|
||||
@@ -15,6 +15,21 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({
|
||||
})
|
||||
|
||||
describe('ocean bookings api', () => {
|
||||
it.each([
|
||||
['宁波', '洛杉矶', 'CNNGB', 'USLAX', '宁波', '洛杉矶'],
|
||||
[null, null, 'CNNGB', 'USLAX', 'CNNGB', 'USLAX'],
|
||||
['', ' ', 'CNNGB', 'USLAX', 'CNNGB', 'USLAX'],
|
||||
['宁波', '洛杉矶', null, null, '宁波', '洛杉矶'],
|
||||
[null, '', null, ' ', null, null],
|
||||
])('列表港口名称 %s / %s 为空时展示已保存代码', async (departurePortName, destinationPortName, departurePortCode, destinationPortCode, pol, pod) => {
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: {
|
||||
records: [{ ...detailResponse().oceanBooking, departurePortName, destinationPortName, departurePortCode, destinationPortCode }],
|
||||
page: 1, size: 20, total: 1, pages: 1,
|
||||
} })
|
||||
const result = await listOceanBookings({ page: 1, size: 20 })
|
||||
expect(result.records[0]).toMatchObject({ pol, pod, departurePortName, destinationPortName, departurePortCode, destinationPortCode })
|
||||
})
|
||||
|
||||
it('预计到港使用窄命令并规范化详情回显与列表到港字段', async () => {
|
||||
const payload = { expectedVersion: 3, estimatedArrivalAt: '2026-10-25T10:00:00' }
|
||||
const raw = detailResponse(4, 'RELEASED')
|
||||
|
||||
@@ -27,8 +27,8 @@ function booking(raw: OceanBookingResource): OceanBookingResource {
|
||||
actualPackageCount: raw.actualPackageCount == null ? null : Number(raw.actualPackageCount),
|
||||
actualGrossWeightKg: raw.actualGrossWeightKg == null ? null : Number(raw.actualGrossWeightKg),
|
||||
actualVolumeCbm: raw.actualVolumeCbm == null ? null : Number(raw.actualVolumeCbm),
|
||||
pol: raw.pol ?? raw.departurePortName ?? null,
|
||||
pod: raw.pod ?? raw.destinationPortName ?? null,
|
||||
pol: raw.departurePortName?.trim() || raw.departurePortCode?.trim() || null,
|
||||
pod: raw.destinationPortName?.trim() || raw.destinationPortCode?.trim() || null,
|
||||
carrierName: raw.carrierName ?? raw.shippingCompanyName ?? null,
|
||||
bookingAgent: raw.bookingAgent ?? raw.bookingAgentName ?? null,
|
||||
etd: raw.etd ?? raw.estimatedSailingAt ?? null,
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('OceanBookingDocumentsPanel contract', () => {
|
||||
|
||||
it('makes released booking files permanently read-only and preserves traced errors', () => {
|
||||
expect(source).toContain("props.bookingStatus === 'DRAFT' && props.canManage")
|
||||
expect(source).toContain('已放舱 · 永久只读')
|
||||
expect(source).not.toContain('已放舱 · 永久只读')
|
||||
expect(source).toContain('manageReason')
|
||||
expect(source).toContain('<ApiErrorMeta :details="error" />')
|
||||
expect(source).toContain('订舱资料上传失败,请稍后重试')
|
||||
|
||||
@@ -111,7 +111,7 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
|
||||
<template>
|
||||
<section class="booking-documents workbench-panel is-compact admin-density admin-workbench" aria-label="订舱资料">
|
||||
<div class="booking-documents__heading"><h2>订舱资料<InlineHelp title="订舱资料说明"><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></InlineHelp></h2><el-tag v-if="bookingStatus === 'RELEASED'" type="info">已放舱 · 永久只读</el-tag></div>
|
||||
<div class="booking-documents__heading"><h2>订舱资料<InlineHelp title="订舱资料说明"><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></InlineHelp></h2></div>
|
||||
<el-alert v-if="bookingStatus === 'DRAFT' && !editable && manageReason" type="warning" :closable="false" show-icon :title="manageReason" />
|
||||
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
|
||||
<div class="booking-document-types">
|
||||
|
||||
@@ -79,7 +79,6 @@ type ViewModel = {
|
||||
hasUnsavedActualChanges: boolean
|
||||
printDisabledReason: string
|
||||
save: () => Promise<void>
|
||||
canManageDocuments: boolean
|
||||
detail: OceanBookingDetail | null
|
||||
actionError: { code?: string; requestId?: string; traceId?: string; message: string } | null
|
||||
actionSaving: boolean
|
||||
@@ -93,7 +92,7 @@ async function mountView() {
|
||||
stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
DetailPageLayout: { template: '<section><slot name="header" /><slot /></section>' },
|
||||
PageToolbar: { props: ['description'], template: '<header><p class="toolbar-description">{{ description }}</p><slot name="actions" /></header>' },
|
||||
PageToolbar: { props: ['title', 'description'], template: '<header><h1>{{ title }}</h1><p v-if="description" class="toolbar-description">{{ description }}</p><slot name="actions" /></header>' },
|
||||
CarrierScheduleQueryPanel: true,
|
||||
},
|
||||
},
|
||||
@@ -253,19 +252,21 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows the route alone in the heading without a single customer and keeps an actual customer name', async () => {
|
||||
it('keeps the booking number without a subtitle for empty and populated route data', async () => {
|
||||
const value = booking()
|
||||
value.customerId = null; value.customerName = null
|
||||
value.oceanBooking.customerId = null; value.oceanBooking.customerName = null
|
||||
value.pol = null; value.pod = null
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(value)
|
||||
const { wrapper } = await mountView()
|
||||
expect(wrapper.get('.toolbar-description').text()).toBe('上海 → 洛杉矶')
|
||||
expect(wrapper.get('.booking-page-toolbar h1').text()).toBe(value.bookingNo)
|
||||
expect(wrapper.find('.toolbar-description').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('未关联客户')
|
||||
wrapper.unmount()
|
||||
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking())
|
||||
const { wrapper: customerWrapper } = await mountView()
|
||||
expect(customerWrapper.get('.toolbar-description').text()).toBe('测试客户 · 上海 → 洛杉矶')
|
||||
expect(customerWrapper.find('.toolbar-description').exists()).toBe(false)
|
||||
customerWrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -346,13 +347,14 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('allows documents and release for a saved booking with no single customer', async () => {
|
||||
it('allows release without documents for a saved booking with no single customer', async () => {
|
||||
const empty = booking()
|
||||
empty.customerId = null; empty.customerName = null; empty.oceanBooking.customerId = null; empty.oceanBooking.customerName = null
|
||||
empty.documents = []
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(empty)
|
||||
vi.mocked(releaseOceanBooking).mockResolvedValue({ ...empty, status: 'RELEASED', version: 4 })
|
||||
const { vm, wrapper } = await mountView()
|
||||
expect(vm.canManageDocuments).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('订舱资料')
|
||||
await vm.submitRelease()
|
||||
expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 3 })
|
||||
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
|
||||
|
||||
@@ -73,9 +73,8 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value || arrivalSaving.value)')
|
||||
expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"')
|
||||
expect(source).not.toContain('releaseReason')
|
||||
expect(source).toContain('放舱前请上传有效的订舱确认文件或提单文件')
|
||||
expect(source).toContain('OceanBookingDocumentsPanel')
|
||||
expect(source).toContain('@updated="updateDocuments"')
|
||||
expect(source).not.toContain('放舱前请上传有效的订舱确认文件或提单文件')
|
||||
expect(source).not.toContain('OceanBookingDocumentsPanel')
|
||||
})
|
||||
|
||||
it('freezes the selected carrier standard code into the booking draft', () => {
|
||||
|
||||
@@ -38,7 +38,6 @@ import type {
|
||||
OceanBookingAgentOption,
|
||||
OceanBookingCarrierCode,
|
||||
OceanBookingDraftData,
|
||||
OceanBookingDocument,
|
||||
OceanBookingParty,
|
||||
OceanBookingSaveRequest,
|
||||
OceanBookingStatus,
|
||||
@@ -49,7 +48,6 @@ import { containerRequirementError, createBlankContainerRequirement, selectConta
|
||||
import { shouldClearBookingAgent } from '../bookingAgents'
|
||||
import { oceanBookingPrintSignature, oceanBookingWorkflow, type OceanBookingPreviewSignatures } from '../workflow'
|
||||
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
|
||||
import OceanBookingDocumentsPanel from '../components/OceanBookingDocumentsPanel.vue'
|
||||
|
||||
const partyTypes = [
|
||||
{ value: 'SHIPPER', label: '发货人' },
|
||||
@@ -113,8 +111,6 @@ const formWritable = computed(() => canSave.value)
|
||||
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
|
||||
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
|
||||
const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:ocean-booking:list'))
|
||||
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:edit'))
|
||||
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : '')
|
||||
const actualSaving = ref(false)
|
||||
const arrivalDialogOpen = ref(false)
|
||||
const arrivalSaving = ref(false)
|
||||
@@ -419,16 +415,8 @@ function releaseValidationError() {
|
||||
}
|
||||
if (form.data.estimatedArrivalAt && form.data.estimatedSailingAt && form.data.estimatedArrivalAt < form.data.estimatedSailingAt) return '预计到港时间不能早于预计开船时间'
|
||||
if (form.data.parties.some((party) => !party.partyName.trim())) return '放舱前请填写发货人、收货人和通知人名称'
|
||||
if (!detail.value?.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '放舱前请上传有效的订舱确认文件或提单文件'
|
||||
return containerRequirementError(form.data.containerRequirements)
|
||||
}
|
||||
function updateDocuments(documents: OceanBookingDocument[], bookingVersion: number) {
|
||||
if (!detail.value) return
|
||||
detail.value.documents = documents
|
||||
detail.value.version = bookingVersion
|
||||
detail.value.oceanBooking.version = bookingVersion
|
||||
form.expectedVersion = bookingVersion
|
||||
}
|
||||
async function submitRelease() {
|
||||
if (!detail.value || workflowBusy.value) return
|
||||
actionSaving.value = true
|
||||
@@ -480,7 +468,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<div class="detail-heading">
|
||||
<el-button link class="density-back" @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<span class="booking-brand" aria-hidden="true"><Ship :size="22" /></span>
|
||||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱信息管理'" :description="detail ? `${detail.customerName ? `${detail.customerName} · ` : ''}${detail.pol || '—'} → ${detail.pod || '—'}` : '出口订舱作业'">
|
||||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱信息管理'">
|
||||
<template #actions>
|
||||
<div class="actions booking-page-actions">
|
||||
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
|
||||
@@ -640,7 +628,6 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-no="detail.bookingNo" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
|
||||
|
||||
<div v-if="detail" class="workbench-panel is-compact"><CarrierScheduleQueryPanel dense :ocean-booking-id="detail.id" /></div>
|
||||
|
||||
@@ -695,7 +682,6 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
}
|
||||
.booking-page-toolbar { min-width: 0; flex: 1; }
|
||||
.booking-page-toolbar :deep(h1) { font-size: var(--admin-font-title); }
|
||||
.booking-page-toolbar :deep(.page-toolbar__description) { margin-top: var(--admin-space-1); font-size: var(--admin-font-caption); color: var(--workbench-muted); }
|
||||
.booking-page-actions { align-items: center; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.booking-workflow {
|
||||
|
||||
@@ -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,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,328 @@
|
||||
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.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 : '', 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_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>
|
||||
|
||||
@@ -40,6 +40,42 @@ 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('switches to domestic warehouses, clears old selection and preserves editable direction after failure', async () => {
|
||||
vi.mocked(listOverseasPurchaseWarehouseOptions).mockImplementation(async direction => direction === 'DOMESTIC_TO_OVERSEAS'
|
||||
? [{ warehouseId: '30001', warehouseCode: 'SZX', warehouseName: '国内采购仓', countryCode: 'CN' }]
|
||||
: [{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '海外采购仓', countryCode: 'US' }])
|
||||
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper)
|
||||
const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!
|
||||
field('采购仓').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001')
|
||||
field('采购方向').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DOMESTIC_TO_OVERSEAS'); await flushPromises()
|
||||
expect(listOverseasPurchaseWarehouseOptions).toHaveBeenLastCalledWith('DOMESTIC_TO_OVERSEAS')
|
||||
expect(field('采购仓').findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('')
|
||||
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
field('采购仓').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '30001')
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('connection lost'))
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ procurementDirection: 'DOMESTIC_TO_OVERSEAS', purchaseWarehouseId: '30001' }))
|
||||
const original = vi.mocked(submitProcurementCommand).mock.calls[0]![3]
|
||||
expect(sessionStorage.getItem(storageKey)).toBeNull()
|
||||
expect(field('采购方向').findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('DOMESTIC_TO_OVERSEAS')
|
||||
await confirm(wrapper)
|
||||
const next = vi.mocked(submitProcurementCommand).mock.calls[1]![3]
|
||||
expect(next.procurementDirection).toBe('DOMESTIC_TO_OVERSEAS')
|
||||
expect(next.purchaseWarehouseId).toBe('30001')
|
||||
expect(next.idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
})
|
||||
it('ignores late warehouse responses from a previous direction', async () => {
|
||||
let finishImport!: (rows: Awaited<ReturnType<typeof listOverseasPurchaseWarehouseOptions>>) => void
|
||||
vi.mocked(listOverseasPurchaseWarehouseOptions).mockImplementation(direction => direction === 'DOMESTIC_TO_OVERSEAS'
|
||||
? Promise.resolve([{ warehouseId: '30001', warehouseCode: 'SZX', warehouseName: '国内采购仓', countryCode: 'CN' }])
|
||||
: new Promise(resolve => { finishImport = resolve }))
|
||||
const wrapper = render(); const dialog = await open(wrapper)
|
||||
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购方向')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DOMESTIC_TO_OVERSEAS'); await flushPromises()
|
||||
finishImport([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '过期海外候选', countryCode: 'US' }]); await flushPromises()
|
||||
const warehouse = dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购仓')!.findComponent({ name: 'ElSelect' })
|
||||
expect(warehouse.findAllComponents({ name: 'ElOption' }).map(item => item.props('value'))).toEqual(['30001'])
|
||||
})
|
||||
|
||||
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,8 +264,8 @@ 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 () => {
|
||||
|
||||
@@ -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, procurementDirections, regionText, type ProcurementDirection } from '../procurementDirection'
|
||||
const props = defineProps<{ detail: PlatformProcurement; customerId: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const auth = useAuthStore(); const access = useAccessPreviewStore()
|
||||
@@ -38,7 +39,9 @@ const error = ref<ApiErrorDetails | null>(null); const storageError = ref(''); c
|
||||
const editingDraft = ref(false); const correctionDraft = ref<ProcurementCorrectionDraft | null>(null); const draftError = ref('')
|
||||
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 form = reactive({ procurementDirection: (props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC') as ProcurementDirection, decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
|
||||
const warehouseDirection = computed(() => command.value === 'review' ? form.procurementDirection : props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC')
|
||||
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 +53,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)
|
||||
@@ -78,13 +81,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)
|
||||
@@ -92,7 +98,7 @@ function open(action: ProcurementAction) {
|
||||
finance.value = financeForm(props.detail); financialCommand.value = 'fees/correct-target'
|
||||
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: '' })
|
||||
Object.assign(form, { procurementDirection: props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC', 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()
|
||||
if (needsWarehouse.value) void loadWarehouses()
|
||||
}
|
||||
@@ -135,7 +141,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: form.procurementDirection, 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')
|
||||
@@ -204,7 +210,7 @@ async function upload(event: Event) {
|
||||
<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">
|
||||
<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,16 +219,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="采购方向" required><el-select v-model="form.procurementDirection"><el-option v-for="(label, value) in procurementDirections" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
||||
<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="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? t('实际海外收货仓') : '采购仓'" 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>
|
||||
</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"><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>
|
||||
</AppDialog>
|
||||
</section>
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 { directionLabel, regionText } from '../procurementDirection'
|
||||
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 : '')
|
||||
@@ -30,10 +31,12 @@ 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 order = computed(() => detail.value?.customerView.order)
|
||||
const t = (text: string) => regionText(text, order.value?.procurementDirection)
|
||||
const fields = computed(() => [
|
||||
['采购方向', directionLabel(order.value?.procurementDirection)],
|
||||
['采购品类', 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],
|
||||
[t('预计到海外仓'), dateTime(order.value?.estimatedArrivalAt)], ['实际收货仓', order.value?.actualWarehouseName],
|
||||
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['下单收货地址', order.value?.receivingAddress], ['内部备注', detail.value?.internalRemark],
|
||||
] as const)
|
||||
const purchaseFields = computed(() => [
|
||||
@@ -43,14 +46,14 @@ const purchaseFields = computed(() => [
|
||||
] as const)
|
||||
const receiptColumns: DataTableColumn[] = [
|
||||
{ key: 'receiptNo', prop: 'receiptNo', label: '实收编号', minWidth: 180 },
|
||||
{ key: 'leg', label: '收货段', width: 100, formatter: (row: ProcurementReceipt) => row.leg === 'OVERSEAS' ? '海外' : '国内' },
|
||||
{ key: 'leg', label: '收货段', width: 100, formatter: (row: ProcurementReceipt) => t(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: 'leg', label: '发生段', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => t(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 },
|
||||
@@ -68,7 +71,7 @@ const shipmentColumns: DataTableColumn[] = [
|
||||
{ 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: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'purpose', label: '用途', minWidth: 140, formatter: (row: ProcurementFee) => t(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)}` },
|
||||
@@ -152,7 +155,7 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
<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 #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, order.procurementDirection)" /></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>
|
||||
<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">
|
||||
@@ -164,7 +167,7 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
<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-if="detail.transport.domesticCargo.length"><h3>{{ t('国内包装与可寄货物核对') }}</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>
|
||||
|
||||
@@ -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)
|
||||
@@ -27,7 +28,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
|
||||
{ key: 'purchaseCategory', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
|
||||
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
|
||||
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
|
||||
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label: ({ WAIT_OVERSEAS_RECEIPT: '待起运仓收货', OVERSEAS_RECEIVING: '起运地收货中', WAIT_OVERSEAS_SHIPMENT: '待国际交寄', DOMESTIC_RECEIVING: '目的地收货中', WAIT_CHARGES: '待目的地核费' } as Record<string, string>)[value] || label })) },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
|
||||
@@ -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 #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 } } })
|
||||
}
|
||||
async function click(wrapper: VueWrapper, label: string) {
|
||||
const button = wrapper.findAll('button').find(item => item.text() === label)
|
||||
@@ -56,6 +57,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')
|
||||
|
||||
@@ -14,6 +14,7 @@ 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'
|
||||
|
||||
@@ -85,6 +86,7 @@ watch([orderNo, customerId], load, { immediate: true })
|
||||
<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"><BusinessDocumentFees business-type="US_TRUCK" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></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>
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
import { regionText } from './procurementDirection'
|
||||
import type { Amount, ProcurementFee } from './procurementTypes'
|
||||
|
||||
export const procurementStatuses: Record<string, string> = {
|
||||
@@ -10,7 +11,7 @@ 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] || '待核对进度' : '—', direction) }
|
||||
export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
|
||||
if (status === 'SHIPPED_TO_CUSTOMER') return 'success'
|
||||
if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export interface OverseasPurchaseQuery {
|
||||
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() } }
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
- 运输路线地图:`components/TransportRouteDialog.vue`经`index.ts`公开导出,排车装车后、本土散板列表、本土卡车表单共用;来源字段、缺值规则和原型映射唯一见`PROTOTYPE_MATRIX.md`“运输路线地图”。仓库引用经`routeLocations.ts`复用Warehouse列表/详情解析完整地址(warehouse:list);解析失败不加载地图。仅规划路线,不接GPS、不用固定样例,不在车辆主档顶部重复放入口。
|
||||
|
||||
- 当前散板需求:正式列表使用`pages/DispatchCapacityDemandsView.vue`、`api/dispatchCapacityDemands.ts`,只读消费日常派送排车的`/dispatches/capacity-demands`摘要;发布选项在排车新建/维护车辆中默认勾选,规则见拼车主契约当前页面范围。不得以实托数推算标准托位,未知余位明确待确认,不读取客户或司机私人资料。原登记和旧车次入口代码保留隐藏。
|
||||
- 当前散板需求:正式列表使用`pages/DispatchCapacityDemandsView.vue`、`api/dispatchCapacityDemands.ts`,只读消费日常派送排车的`/dispatches/capacity-demands`摘要;发布选项仅在智能排车建单时选择,默认不勾选;维护车辆不提供发布修改,预约日历建单不自动发布,重发先取消原排车再重新创建,规则见拼车主契约当前页面范围。不得以实托数推算标准托位,未知余位明确待确认,不读取客户或司机私人资料。原登记和旧车次入口代码保留隐藏。
|
||||
|
||||
- 保留的内部拼车实现:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
|
||||
|
||||
@@ -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项验证结果;未部署、未做真机/人工截图验收。
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getResourcePhoto, uploadResourcePhoto, downloadResourcePhoto } from './resourcePhotos'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
|
||||
it('preserves long identifiers and versions and submits the correct resource scope', async () => {
|
||||
const id = '9223372036854775701', fileId = '9223372036854775702', version = '9007199254740993'
|
||||
const wire = { code: '200', data: { recordVersion: version, photo: { fileId, fileName: 'photo.png', contentType: 'image/png', sizeBytes: '3' } } }
|
||||
vi.mocked(http.get).mockResolvedValue(wire); vi.mocked(http.post).mockResolvedValue(wire)
|
||||
expect(await getResourcePhoto('VEHICLE', id)).toMatchObject({ recordVersion: version, photo: { fileId, sizeBytes: 3 } })
|
||||
await uploadResourcePhoto('VEHICLE', id, version, new File(['png'], 'photo.png', { type: 'image/png' }))
|
||||
expect(http.post).toHaveBeenCalledWith(`/platform/v1/last-leg/fleet/resource-photos/VEHICLE/${id}`, expect.any(FormData))
|
||||
expect((vi.mocked(http.post).mock.calls[0]![1] as FormData).get('recordVersion')).toBe(version)
|
||||
vi.mocked(http.get).mockResolvedValue(new Blob(['{"code":"A0403"}'], { type: 'application/json' }))
|
||||
await expect(downloadResourcePhoto('VEHICLE', id, fileId)).rejects.toThrow('读取失败')
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type ResourcePhotoKind = 'VEHICLE' | 'DRIVER'
|
||||
export interface ResourcePhoto { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||
export interface ResourcePhotoDetail { recordVersion: string; photo: ResourcePhoto | null }
|
||||
const path = (kind: ResourcePhotoKind, id: string) => `/platform/v1/last-leg/fleet/resource-photos/${kind}/${encodeURIComponent(id)}`
|
||||
type Wire = { recordVersion: string | number; photo: (Omit<ResourcePhoto, 'sizeBytes'> & { sizeBytes: number | string }) | null }
|
||||
function normalize(value: Wire): ResourcePhotoDetail {
|
||||
return { recordVersion: String(value.recordVersion), photo: value.photo ? { ...value.photo, sizeBytes: Number(value.photo.sizeBytes) } : null }
|
||||
}
|
||||
export async function getResourcePhoto(kind: ResourcePhotoKind, id: string) {
|
||||
return normalize(unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(kind, id))))
|
||||
}
|
||||
export async function uploadResourcePhoto(kind: ResourcePhotoKind, id: string, version: string, file: File) {
|
||||
const body = new FormData(); body.append('recordVersion', version); body.append('file', file)
|
||||
return normalize(unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(kind, id), body)))
|
||||
}
|
||||
export async function downloadResourcePhoto(kind: ResourcePhotoKind, id: string, fileId: string) {
|
||||
const blob = await http.get<Blob, Blob>(`${path(kind, id)}/${encodeURIComponent(fileId)}/content`, { responseType: 'blob' })
|
||||
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size || blob.size > 10 * 1024 * 1024) throw new Error('档案照片读取失败,请重新核对')
|
||||
return blob
|
||||
}
|
||||
@@ -24,7 +24,7 @@ describe('OutsourcedBusiness presentation boundaries', () => {
|
||||
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } })
|
||||
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
|
||||
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
|
||||
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证'])
|
||||
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['司机驾驶证', '车牌照片', '装车完成', '封车照片', 'BOL司机签字'])
|
||||
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
|
||||
})
|
||||
|
||||
@@ -37,12 +37,15 @@ describe('OutsourcedBusiness presentation boundaries', () => {
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
|
||||
it('unlocks operations closing when the request ends and still respects the owner guard', async () => {
|
||||
const done = vi.fn(), guard = vi.fn()
|
||||
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
|
||||
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, busy: true } })
|
||||
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
|
||||
close(done); expect(done).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ busy: false }); close(done)
|
||||
expect(done).toHaveBeenCalledTimes(1)
|
||||
await wrapper.setProps({ beforeClose: guard }); close(done)
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ function close(done: () => void) {
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
|
||||
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</InlineHelp></p>
|
||||
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">装车资料每类最多5张,支持 JPG、PNG,可逐张替换。此处补充照片不改变装车状态。</InlineHelp></p>
|
||||
<slot name="state" />
|
||||
|
||||
<div class="outsourced-business-photo-grid">
|
||||
|
||||
@@ -62,3 +62,16 @@ describe('outsourced business per-slot photo save',()=>{
|
||||
await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders all saved images and replaces only the chosen file', async () => {
|
||||
const detail=outsourcedBusinessFixture()
|
||||
detail.photos=[{...photo,savedAt:'2026-10-26T00:00:00Z'},{...photo,fileId:'222',fileName:'second.png',savedAt:'2026-10-26T00:00:00Z'}]
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('loading.png');expect(wrapper.text()).toContain('second.png')
|
||||
const input=wrapper.findAll('input[type="file"]')[1]!
|
||||
Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['new'],'new.png',{type:'image/png'})]})
|
||||
await input.trigger('change');await flushPromises()
|
||||
expect(calls.bind).toHaveBeenLastCalledWith(expect.any(String),'LOADING_SCENE',expect.objectContaining({replaceFileId:'222'}))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -16,10 +16,10 @@ const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
|
||||
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const urls = ref<Partial<Record<OutsourcedBusinessPhotoSlot,string>>>({}), reading = ref<OutsourcedBusinessPhotoSlot | null>(null)
|
||||
const urls = ref<Record<string,string>>({}), reading = ref<string | null>(null)
|
||||
const uploadable = computed(() => access.can(permission.outsourcedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
|
||||
let actor = '', number = '', generation = 0
|
||||
function savedPhoto(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
|
||||
function savedPhotos(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.filter(photo => photo.slot === slot && photo.savedAt) || [] }
|
||||
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
|
||||
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
|
||||
watch(busy, value => emit('busy', value))
|
||||
@@ -30,17 +30,18 @@ watch(open, value => {
|
||||
}, { immediate: true })
|
||||
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
|
||||
onBeforeUnmount(() => { generation++; release() })
|
||||
async function preview(slot: OutsourcedBusinessPhotoSlot) {
|
||||
const photo = savedPhoto(slot), current = generation
|
||||
async function preview(slot: OutsourcedBusinessPhotoSlot, fileId: string) {
|
||||
const photo = savedPhotos(slot).find(item => item.fileId === fileId), current = generation
|
||||
if (!photo || reading.value) return
|
||||
reading.value = slot; error.value = null
|
||||
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
|
||||
reading.value = fileId; error.value = null
|
||||
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[fileId] = URL.createObjectURL(blob) }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||
finally { if (current === generation) reading.value = null }
|
||||
}
|
||||
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
|
||||
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event, replaceFileId?: string) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || !uploadable.value) return
|
||||
if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
@@ -48,7 +49,7 @@ async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
|
||||
const photo = await uploadOutsourcedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||
const latest = await getOutsourcedBusiness(number); ensureScope()
|
||||
if (!latest.row.actions.upload.allowed) throw new Error(latest.row.actions.upload.blockReason || '当前不允许上传照片')
|
||||
const result = await bindOutsourcedBusinessPhoto(number, slot, { recordVersion: outsourcedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
|
||||
const result = await bindOutsourcedBusinessPhoto(number, slot, { recordVersion: outsourcedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) }); ensureScope()
|
||||
emit('saved', result); ElMessage.success('照片已保存')
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
||||
finally { busy.value = false }
|
||||
@@ -60,10 +61,13 @@ function beforeClose(done: () => void) { if (!busy.value) done() }
|
||||
<OutsourcedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
|
||||
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
|
||||
<template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
|
||||
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="outsourced-business-photo" />
|
||||
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
|
||||
<p v-else>暂无已保存照片</p>
|
||||
<label v-if="uploadable" class="outsourced-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
||||
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
|
||||
<img v-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="outsourced-business-photo" />
|
||||
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看图片</el-button>
|
||||
<label v-if="uploadable" class="outsourced-business-upload">替换图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
|
||||
</div>
|
||||
<p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p>
|
||||
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="outsourced-business-upload">上传图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
||||
</template>
|
||||
</OutsourcedBusinessPhotosDialog>
|
||||
</template>
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('OwnedBusiness presentation boundaries', () => {
|
||||
const wrapper = mount(OwnedBusinessPhotosDialog, { global, props: { modelValue: true } })
|
||||
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
|
||||
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
|
||||
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证', '起始油耗表盘', '结束油耗表盘'])
|
||||
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['司机驾驶证', '车牌照片', '装车完成', '封车照片', 'BOL司机签字', '起始油耗表盘', '结束油耗表盘'])
|
||||
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
|
||||
})
|
||||
|
||||
@@ -37,12 +37,15 @@ describe('OwnedBusiness presentation boundaries', () => {
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
|
||||
it('unlocks operations closing when the request ends and still respects the owner guard', async () => {
|
||||
const done = vi.fn(), guard = vi.fn()
|
||||
const wrapper = mount(OwnedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
|
||||
const wrapper = mount(OwnedBusinessOperationsDialog, { global, props: { modelValue: true, busy: true } })
|
||||
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
|
||||
close(done); expect(done).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ busy: false }); close(done)
|
||||
expect(done).toHaveBeenCalledTimes(1)
|
||||
await wrapper.setProps({ beforeClose: guard }); close(done)
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,13 +10,13 @@ const props = defineProps<{
|
||||
}>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const activeTab = defineModel<'loading' | 'fuel'>('activeTab', { default: 'loading' })
|
||||
// These are prototype presentation slots; the public file categories are not yet frozen.
|
||||
// 展示槽位沿用既有引用,标签与排车装车资料一致。
|
||||
const loadingPhotos = [
|
||||
{ slot: 'loading-scene', label: '装车现场' },
|
||||
{ slot: 'loading-list', label: '装车清单' },
|
||||
{ slot: 'pre-departure-check', label: '出车前检查' },
|
||||
{ slot: 'driver-license', label: '驾驶证附件' },
|
||||
{ slot: 'delivery-receipt', label: '签收凭证' },
|
||||
{ slot: 'loading-scene', label: '司机驾驶证' },
|
||||
{ slot: 'loading-list', label: '车牌照片' },
|
||||
{ slot: 'pre-departure-check', label: '装车完成' },
|
||||
{ slot: 'driver-license', label: '封车照片' },
|
||||
{ slot: 'delivery-receipt', label: 'BOL司机签字' },
|
||||
]
|
||||
const fuelPhotos = [{ slot: 'fuel-start', label: '起始油耗表盘' }, { slot: 'fuel-end', label: '结束油耗表盘' }]
|
||||
function close(done: () => void) {
|
||||
@@ -28,7 +28,7 @@ function close(done: () => void) {
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
|
||||
<p class="owned-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</InlineHelp></p>
|
||||
<p class="owned-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">装车资料每类最多5张,油耗每类1张;支持 JPG、PNG,可逐张替换。此处补充照片不改变装车状态。</InlineHelp></p>
|
||||
<slot name="state" />
|
||||
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
|
||||
@@ -50,3 +50,16 @@ describe('owned business per-slot photo save',()=>{
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders all saved images and replaces only the chosen file', async () => {
|
||||
const detail=ownedBusinessFixture()
|
||||
detail.photos=[{...photo,savedAt:'2026-10-26T00:00:00Z'},{...photo,fileId:'222',fileName:'second.png',savedAt:'2026-10-26T00:00:00Z'}]
|
||||
const wrapper=mount(OwnedBusinessPhotosEditor,{global,props:{modelValue:true,detail}})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('loading.png');expect(wrapper.text()).toContain('second.png')
|
||||
const input=wrapper.findAll('input[type="file"]')[1]!
|
||||
Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['new'],'new.png',{type:'image/png'})]})
|
||||
await input.trigger('change');await flushPromises()
|
||||
expect(calls.bind).toHaveBeenLastCalledWith(expect.any(String),'LOADING_SCENE',expect.objectContaining({replaceFileId:'222'}))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -16,10 +16,10 @@ const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ detail: OwnedBusinessDetail | null }>()
|
||||
const emit = defineEmits<{ saved: [value: OwnedBusinessDetail]; busy: [value: boolean] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const urls = ref<Partial<Record<OwnedBusinessPhotoSlot,string>>>({}), reading = ref<OwnedBusinessPhotoSlot | null>(null)
|
||||
const urls = ref<Record<string,string>>({}), reading = ref<string | null>(null)
|
||||
const uploadable = computed(() => access.can(permission.ownedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
|
||||
let actor = '', number = '', generation = 0
|
||||
function savedPhoto(slot: OwnedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
|
||||
function savedPhotos(slot: OwnedBusinessPhotoSlot) { return props.detail?.photos.filter(photo => photo.slot === slot && photo.savedAt) || [] }
|
||||
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
|
||||
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
|
||||
watch(busy, value => emit('busy', value))
|
||||
@@ -30,17 +30,18 @@ watch(open, value => {
|
||||
}, { immediate: true })
|
||||
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
|
||||
onBeforeUnmount(() => { generation++; release() })
|
||||
async function preview(slot: OwnedBusinessPhotoSlot) {
|
||||
const photo = savedPhoto(slot), current = generation
|
||||
async function preview(slot: OwnedBusinessPhotoSlot, fileId: string) {
|
||||
const photo = savedPhotos(slot).find(item => item.fileId === fileId), current = generation
|
||||
if (!photo || reading.value) return
|
||||
reading.value = slot; error.value = null
|
||||
try { const blob = await getOwnedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
|
||||
reading.value = fileId; error.value = null
|
||||
try { const blob = await getOwnedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[fileId] = URL.createObjectURL(blob) }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||
finally { if (current === generation) reading.value = null }
|
||||
}
|
||||
async function upload(slot: OwnedBusinessPhotoSlot, event: Event) {
|
||||
async function upload(slot: OwnedBusinessPhotoSlot, event: Event, replaceFileId?: string) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || !uploadable.value) return
|
||||
if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
@@ -48,7 +49,7 @@ async function upload(slot: OwnedBusinessPhotoSlot, event: Event) {
|
||||
const photo = await uploadOwnedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||
const latest = await getOwnedBusiness(number); ensureScope()
|
||||
if (!latest.row.actions.upload.allowed) throw new Error(latest.row.actions.upload.blockReason || '当前不允许上传照片')
|
||||
const result = await bindOwnedBusinessPhoto(number, slot, { recordVersion: ownedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
|
||||
const result = await bindOwnedBusinessPhoto(number, slot, { recordVersion: ownedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) }); ensureScope()
|
||||
emit('saved', result); ElMessage.success('照片已保存')
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
||||
finally { busy.value = false }
|
||||
@@ -60,10 +61,13 @@ function beforeClose(done: () => void) { if (!busy.value) done() }
|
||||
<OwnedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
|
||||
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
|
||||
<template v-for="role in ownedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
|
||||
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="owned-business-photo" />
|
||||
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
|
||||
<p v-else>暂无已保存照片</p>
|
||||
<label v-if="uploadable" class="owned-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
||||
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
|
||||
<img v-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="owned-business-photo" />
|
||||
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看图片</el-button>
|
||||
<label v-if="uploadable" class="owned-business-upload">替换图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
|
||||
</div>
|
||||
<p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p>
|
||||
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="owned-business-upload">上传图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
||||
</template>
|
||||
</OwnedBusinessPhotosDialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import ResourcePhotoPanel from './ResourcePhotoPanel.vue'
|
||||
import * as api from '../api/resourcePhotos'
|
||||
const security = vi.hoisted(() => ({ writable: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => security.writable }) }))
|
||||
vi.mock('../api/resourcePhotos', () => ({ getResourcePhoto: vi.fn(), uploadResourcePhoto: vi.fn(), downloadResourcePhoto: vi.fn() }))
|
||||
const photo = { fileId: '81', fileName: 'driver.png', contentType: 'image/png', sizeBytes: 3 }
|
||||
const options = { props: { kind: 'DRIVER' as const, resourceId: '7' }, global: { directives: { loading: () => undefined }, stubs: {
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
|
||||
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' },
|
||||
} } }
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); security.writable = true
|
||||
vi.mocked(api.getResourcePhoto).mockResolvedValue({ recordVersion: '2', photo })
|
||||
URL.createObjectURL = vi.fn().mockReturnValue('blob:photo'); URL.revokeObjectURL = vi.fn()
|
||||
})
|
||||
it('keeps reading independent from upload permission and downloads only on preview', async () => {
|
||||
security.writable = false
|
||||
vi.mocked(api.downloadResourcePhoto).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||
const wrapper = mount(ResourcePhotoPanel, options); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('上传 / 替换')
|
||||
expect(api.downloadResourcePhoto).not.toHaveBeenCalled()
|
||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(api.downloadResourcePhoto).toHaveBeenCalledWith('DRIVER', '7', '81')
|
||||
wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:photo')
|
||||
})
|
||||
it('submits the independent photo version and releases busy state after failure', async () => {
|
||||
vi.mocked(api.uploadResourcePhoto).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409'))
|
||||
.mockResolvedValueOnce({ recordVersion: '3', photo })
|
||||
const wrapper = mount(ResourcePhotoPanel, options); await flushPromises()
|
||||
const input = wrapper.get('input[type="file"]'), file = new File(['png'], 'driver.png', { type: 'image/png' })
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(api.uploadResourcePhoto).toHaveBeenCalledWith('DRIVER', '7', '2', file)
|
||||
expect(wrapper.text()).toContain('版本冲突')
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(api.uploadResourcePhoto).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not apply an upload result to a different resource', async () => {
|
||||
let finish!: (value: api.ResourcePhotoDetail) => void
|
||||
vi.mocked(api.uploadResourcePhoto).mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mount(ResourcePhotoPanel, options); await flushPromises()
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { value: [new File(['png'], 'driver.png', { type: 'image/png' })] })
|
||||
await input.trigger('change')
|
||||
vi.mocked(api.getResourcePhoto).mockResolvedValue({ recordVersion: '0', photo: null })
|
||||
await wrapper.setProps({ resourceId: '8' }); await flushPromises()
|
||||
finish({ recordVersion: '3', photo }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('尚未上传司机照片')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getResourcePhoto, uploadResourcePhoto, downloadResourcePhoto, type ResourcePhotoKind, type ResourcePhotoDetail } from '../api/resourcePhotos'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
|
||||
const props = defineProps<{ kind: ResourcePhotoKind; resourceId: string }>()
|
||||
const access = useAccessPreviewStore(), detail = ref<ResourcePhotoDetail | null>(null), loading = ref(false), busy = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), input = ref<HTMLInputElement | null>(null), previewOpen = ref(false), url = ref('')
|
||||
const label = computed(() => props.kind === 'VEHICLE' ? '车辆图片' : '司机照片')
|
||||
const editable = computed(() => access.can(props.kind === 'VEHICLE' ? permission.vehicleEdit : permission.driverUpload))
|
||||
let generation = 0
|
||||
function closePreview() { previewOpen.value = false; if (url.value) URL.revokeObjectURL(url.value); url.value = '' }
|
||||
async function load() {
|
||||
const current = ++generation; closePreview(); detail.value = null; loading.value = true; error.value = null; busy.value = false
|
||||
try { const result = await getResourcePhoto(props.kind, props.resourceId); if (current === generation) detail.value = result }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '档案照片加载失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
async function upload(event: Event) {
|
||||
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
|
||||
if (!file || !detail.value || busy.value || !editable.value) return
|
||||
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择非空 JPG 或 PNG,最大 10MiB'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
const saved = await uploadResourcePhoto(props.kind, props.resourceId, detail.value.recordVersion, file)
|
||||
if (current !== generation) return
|
||||
detail.value = saved; closePreview(); ElMessage.success(`${label.value}已保存,新排车会自动带入`)
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '照片保存未完成,请重新读取后重试') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
async function preview() {
|
||||
if (!detail.value?.photo || busy.value) return
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
const blob = await downloadResourcePhoto(props.kind, props.resourceId, detail.value.photo.fileId)
|
||||
if (current !== generation) return
|
||||
closePreview(); url.value = URL.createObjectURL(blob); previewOpen.value = true
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '照片预览失败') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
watch(() => [props.kind, props.resourceId], load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; closePreview() })
|
||||
</script>
|
||||
<template>
|
||||
<section v-loading="loading" :aria-label="label">
|
||||
<LoadErrorState v-if="error" title="档案照片操作未完成" :message="error.message" compact @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div class="density-actions">
|
||||
<el-button v-if="detail?.photo" :disabled="busy" @click="preview">查看{{ label }}</el-button><span v-else-if="detail">尚未上传{{ label }}</span>
|
||||
<el-button v-if="editable" :disabled="!detail || busy" :loading="busy" @click="input?.click()">上传 / 替换{{ label }}</el-button>
|
||||
<input ref="input" hidden type="file" accept="image/jpeg,image/png" :aria-label="'上传' + label" @change="upload">
|
||||
</div>
|
||||
<p class="density-dialog-note">排车选择此档案后自动带入照片;替换不影响已保存的排车资料。JPG、PNG,最大 10MiB。</p>
|
||||
<AppDialog :model-value="previewOpen" class="admin-density-dialog" :title="label" width="620px" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview"><img v-if="url" class="resource-photo-preview" :src="url" :alt="label"></AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.resource-photo-preview { display: block; width: 100%; max-height: 60vh; object-fit: contain; }</style>
|
||||
@@ -18,3 +18,6 @@ export { normalizeTripDetail } from './api/trips'
|
||||
export type { TripDetail, TripAssignment, TripResourceRelease } from './tripTypes'
|
||||
export type { PickupCandidate, PickupTask, PickupEvidence, PickupPlan, PickupCommand, PickupConfirmation } from './api/pickups'
|
||||
export { pickupStatusLabels, downloadPickupEvidence } from './api/pickups'
|
||||
|
||||
export { getResourcePhoto, downloadResourcePhoto } from './api/resourcePhotos'
|
||||
export type { ResourcePhotoKind } from './api/resourcePhotos'
|
||||
|
||||
@@ -21,16 +21,16 @@ export interface OutsourcedBusinessPage<T> { records: T[]; page: number; size: n
|
||||
export interface OutsourcedBusinessList extends OutsourcedBusinessPage<OutsourcedBusinessRow> { summary: OutsourcedBusinessSummary }
|
||||
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] }
|
||||
export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null }
|
||||
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
|
||||
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
|
||||
export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
|
||||
export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
|
||||
export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] }
|
||||
export const outsourcedBusinessStages: Record<OutsourcedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
|
||||
export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
|
||||
export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [
|
||||
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' },
|
||||
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' },
|
||||
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' },
|
||||
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' },
|
||||
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' },
|
||||
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: 'BOL司机签字', category: 'LOADING' },
|
||||
]
|
||||
export function outsourcedBusinessVersion(value: unknown): string {
|
||||
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
|
||||
|
||||
@@ -21,16 +21,16 @@ export interface OwnedBusinessPage<T> { records: T[]; page: number; size: number
|
||||
export interface OwnedBusinessList extends OwnedBusinessPage<OwnedBusinessRow> { summary: OwnedBusinessSummary }
|
||||
export interface OwnedBusinessDetail { row: OwnedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OwnedBusinessPhoto[] }
|
||||
export interface OwnedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OwnedBusinessOperations; reason: string | null }
|
||||
export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
|
||||
export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
|
||||
export type OwnedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
|
||||
export interface OwnedBusinessCommand { dispatchNo: string; action: OwnedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OwnedBusinessDetail | OwnedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
|
||||
export interface OwnedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OwnedBusinessOperations | null; photos: OwnedBusinessPhoto[] }
|
||||
export const ownedBusinessStages: Record<OwnedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
|
||||
export const ownedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
|
||||
export const ownedBusinessPhotoRoles: { slot: OwnedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' | 'FUEL' }[] = [
|
||||
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' },
|
||||
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' },
|
||||
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' }, { slot: 'FUEL_START', viewSlot: 'fuel-start', label: '起始油耗表盘', category: 'FUEL' }, { slot: 'FUEL_END', viewSlot: 'fuel-end', label: '结束油耗表盘', category: 'FUEL' },
|
||||
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' },
|
||||
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' },
|
||||
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: 'BOL司机签字', category: 'LOADING' }, { slot: 'FUEL_START', viewSlot: 'fuel-start', label: '起始油耗表盘', category: 'FUEL' }, { slot: 'FUEL_END', viewSlot: 'fuel-end', label: '结束油耗表盘', category: 'FUEL' },
|
||||
]
|
||||
export function ownedBusinessVersion(value: unknown): string {
|
||||
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ResourcePhotoPanel from '../components/ResourcePhotoPanel.vue'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||
@@ -153,6 +154,7 @@ onBeforeUnmount(() => { detailActive = false; resetDetailRequests() })
|
||||
<div v-loading="loading">
|
||||
<template v-if="detail">
|
||||
<FormSection class="density-form-section" title="基础信息"><dl class="density-fields"><div v-for="[label, value] in basic" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div><div class="wide-field"><dt>备注</dt><dd>{{ detail.details.remark || '—' }}</dd></div></dl></FormSection>
|
||||
<FormSection class="density-form-section" title="司机照片"><ResourcePhotoPanel kind="DRIVER" :resource-id="detail.id" /></FormSection>
|
||||
<FormSection class="density-form-section" title="驾驶资质"><dl class="density-fields"><div v-for="[label, value] in credentials" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl></FormSection>
|
||||
<FormSection class="density-form-section" title="证照与派车状态">
|
||||
<div class="driver-document-actions"><StatusBadge :status="driverReviewLabels[detail.reviewStatus]" /><el-button v-if="detail.licenseFileId" @click="showLicense(detail.licenseFileId)">查看驾驶证</el-button><span v-else>尚未上传驾驶证</span><el-button v-if="access.can(permission.driverUpload)" :loading="busy" @click="fileInput?.click()">上传 / 替换驾驶证</el-button><input ref="fileInput" hidden type="file" accept="application/pdf,image/jpeg,image/png" @change="upload" /></div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ResourcePhotoPanel from '../components/ResourcePhotoPanel.vue'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
@@ -19,5 +20,5 @@ function back(){void router.push({name:'overseas-master-data-vehicles'})}
|
||||
watch(() => route.params.vehicleId, () => { activeTab.value='overview';void load() }, { immediate:true })
|
||||
onBeforeUnmount(() => { request++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density admin-workbench" ><DetailPageLayout class="density-detail-layout surface-panel 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="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.plateNumber || '—' }}</h1><StatusBadge v-if="detail" :status="vehicleStatusLabels[detail.status]" /></div></div></template><LoadErrorState v-if="error" title="车辆详情加载失败" :message="error.message" @retry="load"/><section v-loading="loading" class="detail-surface"><el-tabs v-if="detail" v-model="activeTab"><el-tab-pane label="车辆概览" name="overview" /><el-tab-pane label="保养及加油" name="care" /><el-tab-pane label="维修记录" name="REPAIR" /><el-tab-pane label="紧急故障" name="BREAKDOWN" /><el-tab-pane label="应急救援" name="RESCUE" /><el-tab-pane label="异常管理" name="EXCEPTION" /></el-tabs><template v-if="activeTab === 'overview'"><FormSection class="density-form-section" title="车辆信息"><dl v-if="detail" class="detail-grid density-fields"><div v-for="item in [['车牌号',detail.plateNumber],['车型',detail.model],['最大标准托位',detail.maxPalletSpaces??'待确认'],['最大载重(kg)',detail.maxLoadKg??'待确认'],['可用容积(m³)',detail.maxVolumeCbm??'待确认'],['油耗(L/100km)',detail.fuelConsumptionPer100Km],['当前公里码(km)',detail.mileageKm],['司机 / 外派单位',detail.driverOrDispatchCompany||'—'],['联系电话',detail.phone||'—'],['区域',detail.region==='OVERSEAS'?'海外':'国内'],['车辆归属',detail.ownership==='OWNED'?'自有车辆':'外派车辆'],['状态',vehicleStatusLabels[detail.status]],['更新时间',detail.updatedAt],['备注',detail.remark||'—']]" :key="item[0]" :class="{ 'wide-field': ['司机 / 外派单位', '备注'].includes(String(item[0])) }"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl></FormSection><FormSection class="density-form-section" title="派送订单明细"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="deliveries" /></FormSection><FormSection class="density-form-section" title="派送更换记录" :bordered="false"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="changes" /></FormSection></template><div v-if="detail && activeTab === 'care'" :key="detail.id" class="care-grid"><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="MAINTENANCE" /><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="FUEL" /></div><VehicleCareTable v-if="detail && ['REPAIR','BREAKDOWN','RESCUE','EXCEPTION'].includes(activeTab)" :key="`${detail.id}:${activeTab}`" :vehicle-id="detail.id" :plate="detail.plateNumber" :record-type="activeTab as VehicleCareType" /></section></DetailPageLayout></AdminPage></template>
|
||||
<template><AdminPage class="admin-density admin-workbench" ><DetailPageLayout class="density-detail-layout surface-panel 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="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.plateNumber || '—' }}</h1><StatusBadge v-if="detail" :status="vehicleStatusLabels[detail.status]" /></div></div></template><LoadErrorState v-if="error" title="车辆详情加载失败" :message="error.message" @retry="load"/><section v-loading="loading" class="detail-surface"><el-tabs v-if="detail" v-model="activeTab"><el-tab-pane label="车辆概览" name="overview" /><el-tab-pane label="保养及加油" name="care" /><el-tab-pane label="维修记录" name="REPAIR" /><el-tab-pane label="紧急故障" name="BREAKDOWN" /><el-tab-pane label="应急救援" name="RESCUE" /><el-tab-pane label="异常管理" name="EXCEPTION" /></el-tabs><template v-if="activeTab === 'overview'"><FormSection class="density-form-section" title="车辆信息"><dl v-if="detail" class="detail-grid density-fields"><div v-for="item in [['车牌号',detail.plateNumber],['车型',detail.model],['最大标准托位',detail.maxPalletSpaces??'待确认'],['最大载重(kg)',detail.maxLoadKg??'待确认'],['可用容积(m³)',detail.maxVolumeCbm??'待确认'],['油耗(L/100km)',detail.fuelConsumptionPer100Km],['当前公里码(km)',detail.mileageKm],['司机 / 外派单位',detail.driverOrDispatchCompany||'—'],['联系电话',detail.phone||'—'],['区域',detail.region==='OVERSEAS'?'海外':'国内'],['车辆归属',detail.ownership==='OWNED'?'自有车辆':'外派车辆'],['状态',vehicleStatusLabels[detail.status]],['更新时间',detail.updatedAt],['备注',detail.remark||'—']]" :key="item[0]" :class="{ 'wide-field': ['司机 / 外派单位', '备注'].includes(String(item[0])) }"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl></FormSection><FormSection v-if="detail" class="density-form-section" title="车辆图片"><ResourcePhotoPanel kind="VEHICLE" :resource-id="detail.id" /></FormSection><FormSection class="density-form-section" title="派送订单明细"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="deliveries" /></FormSection><FormSection class="density-form-section" title="派送更换记录" :bordered="false"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="changes" /></FormSection></template><div v-if="detail && activeTab === 'care'" :key="detail.id" class="care-grid"><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="MAINTENANCE" /><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="FUEL" /></div><VehicleCareTable v-if="detail && ['REPAIR','BREAKDOWN','RESCUE','EXCEPTION'].includes(activeTab)" :key="`${detail.id}:${activeTab}`" :vehicle-id="detail.id" :plate="detail.plateNumber" :record-type="activeTab as VehicleCareType" /></section></DetailPageLayout></AdminPage></template>
|
||||
<style scoped>.care-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-6)}.detail-grid .wide-field { grid-column: span 2; }</style>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
## 验证
|
||||
|
||||
- 排车调度/理货审核分区仅显示审核、详情;车辆图片、司机照片和驾驶证照片在维护车辆司机时通过既有本车附件接口上传,随资源编辑命令保存,司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。
|
||||
- 排车调度/理货审核分区仅显示审核、详情;车辆图片与司机照片优先从所选主档自动带入,保存时复制为本趟附件;已有当趟照片保留,主档缺失或临时外配时通过既有本车附件接口补传。驾驶证沿主档引用且接受PDF/JPG/PNG;司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。
|
||||
|
||||
- 纯文案/纯展示只核对差异;业务逻辑变化选择直接对应测试,不因文档引用某测试就自动执行。
|
||||
- 转运审核组件/请求逻辑变化:定向选择 `components/WarehouseTransferReviewDialog.test.ts`、`api/warehouseTransfers.test.ts`;入口、权限或状态变化再加 `pages/prototypeContract.test.ts`。
|
||||
|
||||
@@ -317,6 +317,7 @@
|
||||
| 尾程订单 | 出库计划 | `modal-lmo-outbound-plan`,927 行 | 居中弹框,约 760px | 无正式实现 | 明确属于库存离仓,排除,不实现 |
|
||||
| 入库单 | 列表/筛选 | `page-fba-inbound-order`,491–525 行;`fbioSearch()`、`fbioReset()` | 独立列表页,查询区、批量预约/排车、横向宽表 | `/prototype/fba-returns/inbound-orders` 占位 | 已接真实分页、显式客户、筛选、状态、加载/错误/空态;排车字段后端未提供,保持只读空值 |
|
||||
| 入库单 | 详情 | `page-fba-inbound-order-detail`,684–691 行;`openFbaInboundOrderDetail()` 8567–8575 行 | 独立详情页;单据信息、仓储信息、预约与排车三组字段,应收费用明细位于正文 | 正式参数路由与 `InboundOrderDetailView.vue` | 已接计划详情和 `sourceOrderId`;接口未提供的预约/排车时间、备注和费用继续显示空值 |
|
||||
| 入库单 | 确认短少入库 | 用户确认实际短少后的处理要求;Admin规范“简单确认、原因填写” | 详情托盘明细标题区操作,560px居中确认弹框;应收、实收和短少只读,原因必填 | `InboundShortageConfirmation.vue`;仅待差异核对的FBA短少和入库编辑权限可用 | 定向组件/API/页面测试与类型检查通过;1920×1080、100%待人工截图确认 |
|
||||
| 入库单 | 编辑 | `modal-fbio-edit`,527 行 | 居中弹框,约 880px | 无正式实现 | 待海外区域写接口与状态机冻结后实现 |
|
||||
| 入库单 | 预约 | `modal-fbio-appointment`,526 行 | 居中弹框,约 720px | 无正式实现 | 待预约/排车接口与幂等契约冻结后实现 |
|
||||
| 入库单 | 新增 | 页面按钮存在但原型未定义对应可达表单 | 原型未定义 | 无正式实现 | 标记待确认,不自行选弹框/抽屉/独立页 |
|
||||
@@ -448,3 +449,27 @@
|
||||
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;2026-10-22 用户截图确认移除资料核对、油耗和装车照片改表格;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;嵌入编辑器隐藏资料核对;三个页签仅显示各自资料,装车资料只保留整车装车照片,不再附带BOL及车辆/司机附件,保存资料按钮仅在签收页签显示,切换保留签收草稿;油耗两行、装车照片五行表格,操作列在首列,保留预览、上传/替换和原保存行为;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 4文件27项定向测试及受影响组件与测试源码类型检查通过;页签隔离追加修改后5项定向测试及组件/测试类型检查通过;1920×1080、100%待人工截图确认 |
|
||||
|
||||
| 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 |
|
||||
|
||||
|
||||
## 灰度界面精简(2026-10-26)
|
||||
|
||||
依据:本轮用户截图 1—7 及后续五项确认;Admin 基于 main `952abd56` 的 `hotfix-gray-bugfix`。已有入库短收改动保留。
|
||||
|
||||
| 页面 | 操作 | 确认布局与实现 | 验证 |
|
||||
|---|---|---|---|
|
||||
| 尾程订单 | 入库计划 | 保留原居中、可全屏弹框;移除顶部说明,托数使用紧凑输入框,说明靠近输入并自然换行;保存生成入库单及预计到仓锁定规则不变 | 模板类型检查通过;1920×1080、100%待人工截图确认 |
|
||||
| 尾程订单 | 修改节点 | 保留原居中弹框;去掉黄色提示及分区背景,只显示国内报关完成、国外清关完成;读取并原样回传隐藏节点已有值 | 两项编辑、隐藏节点保留、零版本提交回归通过;待人工截图确认 |
|
||||
| 排车列表 | 调度审核图片 | 保持三类图片横向并排;分区顶部对齐,多图按可用宽度并排;缩略图保持完整比例,长文件名省略且可悬停查看,保留大图预览 | 附件组件19项、审核布局29项通过;待人工截图确认 |
|
||||
| 清关提柜详情 | DO/目的港清关附件 | 保留上传弹框,选中文件即上传保存;上传中防重复,失败保留文件可重试;目的港清关完成仍是独立业务动作 | 自动上传、防重复、失败重试共4项回归通过;待人工截图确认 |
|
||||
|
||||
续修验证:修正与当前开船阶段、服务端到仓投影和默认白色分区不一致的旧测试,新增未开船订单只读回归;补齐尾程费用表单的可编辑表格标记、统一订单状态按钮的共享高度变量。Admin 7个文件265项定向检查全部通过,Vue类型检查通过。PDA 381项全量测试全部通过(失败重试按现行规则使用新键与当前输入);此前 H5 与 App 资源构建通过。自动检查不代替真实服务、PDA真机或人工视觉验收。
|
||||
|
||||
## 档案照片自动带入(2026-10-26)
|
||||
|
||||
依据用户确认优化重复上传:维护车辆/司机沿原三列照片布局,缺少本趟照片时展示所选主档照片,并显示保存后用于本趟;允许本趟补传覆盖默认来源。驾驶证允许PDF/JPG/PNG。普通派送及到仓取货均由服务端保存档案照片副本,历史排车保持原图。自动行为测试与类型检查单独报告;布局待人工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