This commit is contained in:
2026-10-27 17:39:19 +08:00
parent b5d8ccdffe
commit c148912b73
49 changed files with 1134 additions and 464 deletions
+2 -2
View File
@@ -50,13 +50,13 @@ describe('adminNavigation', () => {
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配任务', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
{ title: '财务管理', children: ['海外费用明细', '客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
expect(visible).toHaveLength(13)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(58)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(59)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
})
+1 -1
View File
@@ -146,7 +146,6 @@ 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'] },
@@ -166,6 +165,7 @@ export const adminNavigation: AdminNavigationNode[] = [
title: '财务管理',
icon: PrototypeFinanceIcon,
children: [
{ key: 'overseas-fees', title: '海外费用明细', icon: CircleDollarSign, path: '/overseas-business/fees', permission: 'platform:overseas-fee:list' },
{ key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list' },
{ key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list' },
{ key: 'finance-receivable-statistics', title: '应收款统计表', icon: CircleDollarSign, path: '/finance/receivable-statistics', permission: 'platform:finance:receivable-statistics:list' },
+1 -1
View File
@@ -22,7 +22,7 @@
## 关键边界
- 采购样品履约是一条独立链路:下单、包装、面单、交寄、国内核费、寄客户;改节点或费用前先读 `docs/海外仓业务/采购订单样品履约流程变更需求.md`,不套用普通头程或尾程流程。
- 采购按 `baiyue-firstleg/lastleg-service/docs/procurement-workflow.md` 的七状态流程执行:审核、分批收货、客户确认、分批出库及签收扣采购库存;费用复用海外费用窗口,默认余额支付,不恢复旧两段样品节点。
- 本土卡车与散板是海外本地运输业务,区别于国内订舱拖车与客户排车;车型、司机与车队主数据归 `overseas-master-data`,本模块只消费。
- 采购费用入海外费用池后由 `finance/` 记账,本模块不判定收付状态,不重复写入费用行。
- 列表与表单的客户、仓库、供应商候选一律取服务端结果,不从本地或名称推算;长整型 ID 按字符串透传。
@@ -0,0 +1,14 @@
import { beforeEach, expect, it, vi } from 'vitest'
const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
vi.mock('@/shared/api/http', async importOriginal => ({ ...await importOriginal<typeof import('@/shared/api/http')>(), http: api }))
import { getProcurementWorkflow, submitProcurementWorkflow } from './procurementWorkflow'
beforeEach(() => { vi.clearAllMocks(); api.get.mockResolvedValue({ code: '200', data: { orderNo: 'OP/1' } }); api.post.mockResolvedValue({ code: '200', data: { orderNo: 'OP/1' } }) })
it('scopes workflow and every batch operation to the exact order and customer', async () => {
await getProcurementWorkflow('OP/1', '1001')
expect(api.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/workflow', { params: { customerId: '1001' } })
for (const action of ['receipts', 'request-confirmation', 'outbounds', 'outbounds/9007199254740993/dispatch', 'outbounds/9007199254740993/complete']) {
const command = { version: 3, idempotencyKey: 'retry-same-key' }
await submitProcurementWorkflow('OP/1', '1001', action, command)
expect(api.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/overseas-procurements/OP%2F1/${action}`, command, { params: { customerId: '1001' } })
}
})
@@ -0,0 +1,16 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface ProcurementWorkflowReceipt { id: string; quantity: number; fileIds: string[]; receivedAt: string }
export interface ProcurementOutbound { id: string; outboundNo: string; quantity: number; trackingNo: string; status: string; fileIds: string[]; createdAt: string; completedAt: string | null }
export interface ProcurementWorkflow {
orderNo: string; version: number; status: string; receivedQuantity: number; stockQuantity: number; reservedQuantity: number; availableQuantity: number
receipts: ProcurementWorkflowReceipt[]; outbounds: ProcurementOutbound[]
}
export interface WorkflowCommand { version: number; idempotencyKey: string; quantity?: number; trackingNo?: string; fileIds?: string[] }
const base = '/platform/v1/last-leg/overseas-procurements'
export async function getProcurementWorkflow(orderNo: string, customerId: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/workflow`, { params: { customerId } }))
}
export async function submitProcurementWorkflow(orderNo: string, customerId: string, action: string, payload: WorkflowCommand) {
return unwrapApiResponse(await http.post<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/${action}`, payload, { params: { customerId } }))
}
@@ -68,6 +68,18 @@ beforeEach(() => {
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('单据费用操作', () => {
it('uses the same fee dialog for procurement with balance deduction selected by default', async () => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
const procurement = { ...business, businessType: 'OVERSEAS_PROCUREMENT', businessNo: 'OP-1' }
const wrapper = mount(BusinessOrderFees, { props: { business: procurement }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await flushPromises(); await click(wrapper, '新增费用')
const dialog = activeDialog(wrapper)
const form = dialog.getComponent({ name: 'ElForm' }).props('model')
expect(form.settlementMode).toBe('WALLET')
Object.assign(form, { feeItemCode: 'HANDLING', amount: 12.34 })
await click(dialog, '保存')
expect(api.createOverseasFee).toHaveBeenCalledWith(expect.objectContaining({ businessType: 'OVERSEAS_PROCUREMENT', sourceType: 'OVERSEAS_PROCUREMENT_FEE', businessId: procurement.businessId, customerId: procurement.customerId, settlementMode: 'WALLET', amount: 12.34 }), expect.any(String))
})
it.each(['RECEIVABLE', 'PAYABLE'] as const)('hides an empty %s block and reveals it when fees are added', async direction => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
const wrapper = mount(BusinessOrderFees, { props: { business, direction, hideWhenEmpty: true, canCreate: false, heading: direction === 'RECEIVABLE' ? '应收明细' : '应付明细' }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
@@ -70,7 +70,7 @@ watch(open, async value => {
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() })
Object.assign(form, { businessType: props.business?.businessType || fee?.businessType || 'WAREHOUSE_TRANSFER', businessId: props.business?.businessId || fee?.businessId || '', feeItemCode: fee?.feeItemCode || '', feeRemark: fee?.feeRemark || '', settlementMode: fee?.direction === 'RECEIVABLE' && (fee?.settlementMode === 'BILL' || fee?.settlementMode === 'WALLET') ? fee.settlementMode : props.business?.businessType === 'OVERSEAS_PROCUREMENT' ? 'WALLET' : '', currency: fee?.originalCurrency || 'USD', amount: Number(fee?.originalAmount || 0), businessDate: fee?.businessDate || today() })
options.value = fee ? [{ businessId: fee.businessId || '', businessType: fee.businessType, businessNo: fee.businessNo, customerId: fee.customerId, customerName: fee.customerName, orderId: fee.orderId, warehouseId: fee.warehouseId }] : []
captureBaseline()
formRef.value?.clearValidate()
@@ -79,7 +79,7 @@ watch(open, async value => {
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',
LOCAL_LOOSEBOARD: 'OVERSEAS_LOOSEBOARD_FEE', OVERSEAS_PROCUREMENT: 'OVERSEAS_PROCUREMENT_FEE',
OVERSEAS_INBOUND: 'OVERSEAS_INBOUND_FEE', DELIVERY_ORDER: 'OVERSEAS_DELIVERY_FEE',
}
const selectableTypes = Object.keys(sourceTypes)
@@ -130,7 +130,7 @@ async function save() {
<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 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>
@@ -29,8 +29,8 @@ function render(detail = fixture(), permissions = ['review', 'buy', 'cancel', 'r
return mount(ProcurementNodeActions, { attachTo: document.body, props: { detail, customerId: '10001' }, global: { plugins: [pinia, ElementPlus] } })
}
async function open(wrapper: ReturnType<typeof render>) { await wrapper.find('.purchase-actions button').trigger('click'); await flushPromises(); return wrapper.findComponent(AppDialog) }
async function explain(wrapper: ReturnType<typeof render>, value = '已核对客户资料') { await wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '操作说明')!.find('textarea').setValue(value) }
async function confirm(wrapper: ReturnType<typeof render>) { wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() }
async function explain(wrapper: ReturnType<typeof render>, value = '已核对客户资料') { await wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => ['操作说明', '审核说明', '拒绝原因'].includes(row.props('label')))!.find('textarea').setValue(value) }
async function confirm(wrapper: ReturnType<typeof render>) { const dialog = wrapper.findComponent(AppDialog); const approve = dialog.findAll('button').find(button => button.text() === '通过'); if (approve) await approve.trigger('click'); else dialog.vm.$emit('confirm'); await flushPromises() }
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
beforeEach(() => {
@@ -40,40 +40,43 @@ 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.each(['OVERSEAS_TO_DOMESTIC', 'DOMESTIC_TO_OVERSEAS'] as const)('locks review direction and requests the matching warehouse catalog: %s', async direction => {
const value = fixture(); value.customerView.order.procurementDirection = direction
const wrapper = render(value); const dialog = await open(wrapper)
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
const directionField = fields.find(item => item.props('label') === '采购方向')!
expect(directionField.findComponent({ name: 'ElSelect' }).exists()).toBe(false)
expect(directionField.text()).toContain(direction === 'DOMESTIC_TO_OVERSEAS' ? '国内采购,发往海外' : '海外采购,发往国内')
expect(listOverseasPurchaseWarehouseOptions).toHaveBeenLastCalledWith(direction)
expect(fields.some(item => item.props('label') === (direction === 'DOMESTIC_TO_OVERSEAS' ? '国内采购仓' : '海外采购仓'))).toBe(true)
expect(fields.some(item => item.props('label') === '审核决定')).toBe(false)
expect(dialog.findAll('button').map(button => button.text())).toEqual(expect.arrayContaining(['取消', '拒绝', '通过']))
expect(dialog.text()).not.toContain('确认提交')
})
it('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 }))
it('allows approval without a remark after a blank rejection attempt once a warehouse is selected', async () => {
const wrapper = render(); const dialog = await open(wrapper)
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'])
await dialog.findAll('button').find(button => button.text() === '拒绝')!.trigger('click'); await flushPromises()
expect(submitProcurementCommand).not.toHaveBeenCalled()
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '海外采购仓')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001')
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'APPROVE', remark: '', purchaseWarehouseId: '20001' }))
})
it('requires a nonblank rejection reason and lets cancel close without changing the order', async () => {
const wrapper = render(); const dialog = await open(wrapper)
const reject = () => dialog.findAll('button').find(button => button.text() === '拒绝')!
await reject().trigger('click'); await flushPromises()
expect(submitProcurementCommand).not.toHaveBeenCalled()
expect(dialog.text()).toContain('请填写拒绝原因')
await explain(wrapper, ' '); await reject().trigger('click'); await flushPromises()
expect(submitProcurementCommand).not.toHaveBeenCalled()
await explain(wrapper, '供应商无法提供该商品'); await reject().trigger('click'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'REJECT', remark: '供应商无法提供该商品' }))
expect(vi.mocked(submitProcurementCommand).mock.calls[0]![3]).not.toHaveProperty('purchaseWarehouseId')
wrapper.unmount()
const next = render(); const nextDialog = await open(next)
await nextDialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
expect(nextDialog.props('modelValue')).toBe(false)
})
it('disables terminal physical changes and keeps the management evidence form open', async () => {
@@ -264,14 +267,14 @@ describe('procurement purchase node operations', () => {
it('requires an authoritative warehouse on approval and sends the current order version', async () => {
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper)
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('请选择有效采购仓')
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购仓')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper)
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '海外采购仓')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', { version: 3, idempotencyKey: expect.any(String), decision: 'APPROVE', procurementDirection: 'OVERSEAS_TO_DOMESTIC', remark: '已核对客户资料', purchaseWarehouseId: '20001' })
expect(wrapper.emitted('changed')).toHaveLength(1); expect(sessionStorage.getItem(storageKey)).toBeNull()
})
it('rejects without a warehouse and never substitutes an invented candidate', async () => {
vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('unavailable'))
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper, '购买对象不符合要求')
dialog.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECT'); await confirm(wrapper)
await dialog.findAll('button').find(button => button.text() === '拒绝')!.trigger('click'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'REJECT' }))
expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('purchaseWarehouseId')
})
@@ -23,7 +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'
import { directionLabel, regionText } from '../procurementDirection'
const props = defineProps<{ detail: PlatformProcurement; customerId: string }>()
const emit = defineEmits<{ changed: [] }>()
const auth = useAuthStore(); const access = useAccessPreviewStore()
@@ -39,8 +39,10 @@ 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({ 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 form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
const warehouseDirection = computed(() => props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC')
const purchaseWarehouseLabel = computed(() => warehouseDirection.value === 'DOMESTIC_TO_OVERSEAS' ? '国内采购仓' : '海外采购仓')
const rejecting = ref(false)
const t = (text: string) => regionText(text, warehouseDirection.value)
const control = ref(controlForm(props.detail))
const finance = ref(financeForm(props.detail))
@@ -69,7 +71,7 @@ const isFinance = computed(() => !!command.value && financeCommands.has(command.
const isTransport = computed(() => !!command.value && transportCommands.has(command.value))
const isPhysical = computed(() => !!command.value && physicalCommands.has(command.value))
const needsEvidence = computed(() => ['dispositions', 'receipts/correct', 'dispositions/revoke', 'domestic-repack', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(command.value || ''))
const needsWarehouse = computed(() => command.value === 'actual-warehouse/correct' || command.value === 'overseas-receipts' || (command.value === 'receipts/correct' && physical.value.leg === 'OVERSEAS') || command.value === 'planned-warehouse' || (command.value === 'review' && form.decision === 'APPROVE'))
const needsWarehouse = computed(() => command.value === 'actual-warehouse/correct' || command.value === 'overseas-receipts' || (command.value === 'receipts/correct' && physical.value.leg === 'OVERSEAS') || command.value === 'planned-warehouse' || command.value === 'review')
const needsAmount = computed(() => ['purchase', 'budget-authorizations'].includes(command.value || ''))
const fileCategory = computed(() => isControl.value ? 'EXCEPTION' : isFinance.value ? 'VOUCHER' : command.value === 'labels' ? 'LABEL' : command.value === 'domestic-repack' ? 'RECEIPT' : needsEvidence.value ? 'EXCEPTION' : command.value === 'purchase' ? 'PURCHASE' : 'BUDGET')
const allFiles = computed(() => [...props.detail.files, ...extraFiles.value].filter((file, index, all) => all.findIndex(other => other.fileId === file.fileId) === index))
@@ -98,8 +100,8 @@ 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, { 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()
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: '' })
rejecting.value = false; opened.value = true; captureBaseline()
if (needsWarehouse.value) void loadWarehouses()
}
function openCorrectionDraft() {
@@ -131,9 +133,9 @@ watch(financialCommand, () => { if (editingDraft.value) return; finance.value.fe
watch(needsWarehouse, value => { if (value && opened.value) void loadWarehouses() })
function build(): ProcurementCommandBody {
const order = props.detail.customerView.order
if (!form.reason.trim()) throw new Error('请填写操作说明')
if (!form.reason.trim() && (command.value !== 'review' || form.decision === 'REJECT')) throw new Error(command.value === 'review' ? '请填写拒绝原因' : '请填写操作说明')
const base = { version: order.version, idempotencyKey: commandKey(), reason: form.reason.trim() }
if (needsWarehouse.value && !warehouses.value.some(row => row.warehouseId === form.warehouseId)) throw new Error('请选择有效采购仓')
if (needsWarehouse.value && !(command.value === 'review' && form.decision === 'REJECT') && !warehouses.value.some(row => row.warehouseId === form.warehouseId)) throw new Error('请选择有效采购仓')
if (isControl.value && command.value) return buildControl(command.value, control.value, props.detail, allFiles.value, base, form.warehouseId, form.voucherFileId)
if (isFinance.value && command.value) return buildFinance(command.value, finance.value, props.detail, allFiles.value, base, form.voucherFileId)
if (isTransport.value && command.value) return buildTransport(command.value, transport.value, props.detail, allFiles.value, base, form.voucherFileId)
@@ -141,7 +143,7 @@ function build(): ProcurementCommandBody {
if (needsEvidence.value && !files.value.some(file => file.fileId === form.voucherFileId)) throw new Error('请选择本单异常处置凭证')
return buildPhysical(command.value, physical.value, props.detail, base, form.warehouseId, form.voucherFileId)
}
if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, procurementDirection: form.procurementDirection, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) }
if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, procurementDirection: warehouseDirection.value, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) }
if (command.value === 'planned-warehouse') return { ...base, warehouseId: form.warehouseId }
if (needsAmount.value) {
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(form.amount.trim())) throw new Error('请明确填写非负金额,最多两位小数;免费样品请填 0')
@@ -153,6 +155,12 @@ function build(): ProcurementCommandBody {
}
return base
}
async function review(decision: 'APPROVE' | 'REJECT') {
if (saving.value || uploading.value) return
rejecting.value = decision === 'REJECT'
form.decision = decision
await submit()
}
async function submit() {
if (editingDraft.value) { saveCorrectionDraft(); return }
if (saving.value || uploading.value || storageError.value || !key.value) return
@@ -208,7 +216,7 @@ async function upload(event: Event) {
</details>
<el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
<ApiErrorMeta v-if="error && !opened" :details="error" />
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : '确认提交'" @confirm="submit">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : '确认提交'" @confirm="command !== 'review' && submit()">
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
<el-alert v-if="managementBlock" :title="t(managementBlock)" type="warning" :closable="false" />
<el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)">
@@ -219,18 +227,17 @@ async function upload(event: Event) {
<ProcurementFinanceFields v-else-if="isFinance && command" v-model="finance" :command="command" :detail="detail" :files="allFiles" :can="access.can" />
<ProcurementTransportFields v-if="isTransport && command" v-model="transport" :command="command" :detail="detail" :files="allFiles" />
<ProcurementPhysicalFields v-if="isPhysical && command" v-model="physical" :command="command" :detail="detail" />
<el-form-item v-if="command === 'review'" label="采购方向" 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 ? 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>
<el-form-item v-if="command === 'review'" label="采购方向"><span>{{ directionLabel(warehouseDirection) }}</span></el-form-item>
<el-form-item v-if="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? t('实际海外收货仓') : purchaseWarehouseLabel" required><el-select v-model="form.warehouseId" :disabled="isPhysical && !!detail.purchaseFacts.actual_warehouse_id" filterable :loading="warehouseLoading" :placeholder="t('选择有效海外收货仓')"><el-option v-for="item in warehouses" :key="item.warehouseId" :value="item.warehouseId" :label="`${item.warehouseName} · ${item.warehouseCode}`" /></el-select><span v-if="warehouseError">{{ warehouseError }}<el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
<div v-if="needsAmount" class="purchase-form-grid"><el-form-item :label="command === 'purchase' ? '供应商实付总额' : '客户授权预算总额'" required><el-input v-model="form.amount" placeholder="明确填写金额,包括零元" /></el-form-item><el-form-item label="原币" required><el-select v-model="form.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item></div>
<div v-if="command === 'purchase'" class="purchase-form-grid"><el-form-item label="真实购买时间" required><el-date-picker v-model="form.purchasedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item :label="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-item :label="command === 'review' ? (rejecting ? '拒绝原因' : '审核说明') : '操作说明'" :required="command !== 'review' || rejecting"><el-input v-model="form.reason" :placeholder="command === 'review' ? '拒绝时必须填写具体原因' : '请填写操作说明'" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</el-form><el-alert v-if="error" :title="t(error.message)" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
<template #footer><div class="density-actions density-actions--footer"><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>
<template #footer><div class="density-actions density-actions--footer"><template v-if="command === 'review'"><el-button :disabled="saving" @click="opened = false">取消</el-button><el-button type="danger" :loading="saving && form.decision === 'REJECT'" :disabled="saving || !!storageError || !key" @click="review('REJECT')">拒绝</el-button><el-button type="primary" :loading="saving && form.decision === 'APPROVE'" :disabled="saving || warehouseLoading || !!storageError || !key" @click="review('APPROVE')">通过</el-button></template><template v-else><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : '确认提交' }}</el-button></template></div></template>
</AppDialog>
</section>
</template>
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ElementPlus from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ProcurementWorkflowPanel from './ProcurementWorkflowPanel.vue'
import type { PlatformProcurement } from '../procurementTypes'
import type { ProcurementWorkflow } from '../api/procurementWorkflow'
import { submitProcurementWorkflow } from '../api/procurementWorkflow'
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
vi.mock('../api/procurementWorkflow', () => ({ submitProcurementWorkflow: vi.fn() }))
vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() }))
vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() }))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
enableAutoUnmount(afterEach)
const workflow = (status = 'RECEIVING'): ProcurementWorkflow => ({ orderNo: 'OP-1', version: 4, status, receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] })
const detail = { customerView: { order: { purchaseQuantity: 20 } }, files: [] } as unknown as PlatformProcurement
const beforeOutbound = vi.fn<() => Promise<void>>()
function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-customer']) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'test'; auth.permissions = permissions.map(value => `platform:overseas-business:purchase:${value}`); useAccessPreviewStore().initializeAfterLogin()
return mount(ProcurementWorkflowPanel, { props: { detail, workflow: workflow(status), customerId: '1001', beforeOutbound }, global: { plugins: [pinia, ElementPlus] } })
}
beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(undefined); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()); vi.mocked(listOverseasPurchaseWarehouseOptions).mockResolvedValue([]) })
describe('simplified procurement operations', () => {
it('blocks the outbound dialog when fees are missing or not approved and permits retry after approval', async () => {
const wrapper = render('CONFIRMED')
const button = wrapper.findAll('button').find(button => button.text() === '创建出库单')!
beforeOutbound.mockRejectedValueOnce(new Error('请先添加费用明细并审核通过后再出库'))
await button.trigger('click'); await flushPromises()
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
expect(submitProcurementWorkflow).not.toHaveBeenCalled()
beforeOutbound.mockRejectedValueOnce(new Error('费用明细尚未全部审核通过'))
await button.trigger('click'); await flushPromises()
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
await button.trigger('click'); await flushPromises()
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
})
it('gates receipt and outbound actions with the existing operation permissions', () => {
const receipt = render('RECEIVING', [])
expect(receipt.findAll('button').find(button => button.text() === '登记收货')!.attributes('disabled')).toBeDefined()
expect(receipt.text()).toContain('当前账号未开通采购收货权限')
const outbound = render('CONFIRMED', [])
expect(outbound.findAll('button').find(button => button.text() === '创建出库单')!.attributes('disabled')).toBeDefined()
expect(outbound.text()).toContain('当前账号未开通采购出库及签收权限')
expect(render('CONFIRMED').text()).toContain('创建出库单')
})
it('requires receipt photos and uploads them before submitting the current batch quantity', async () => {
const wrapper = render(); await wrapper.findAll('button').find(button => button.text() === '登记收货')!.trigger('click')
const dialog = wrapper.findComponent(AppDialog)
dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).not.toHaveBeenCalled()
vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '1', fileId: '9007199254740993', category: 'RECEIPT', fileName: 'receipt.png', contentType: 'image/png', sizeBytes: 10, createdAt: '' })
const input = document.querySelector('input[type="file"]') || wrapper.find('input[type="file"]').element
Object.defineProperty(input, 'files', { configurable: true, value: [new File(['photo'], 'receipt.png', { type: 'image/png' })] })
input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'receipts', { version: 4, idempotencyKey: expect.any(String), quantity: 1, fileIds: ['9007199254740993'] })
})
it('retains the outbound input and idempotency key after a failed submit', async () => {
vi.mocked(submitProcurementWorkflow).mockRejectedValue(new Error('暂时失败'))
const wrapper = render('CONFIRMED'); await wrapper.findAll('button').find(button => button.text() === '创建出库单')!.trigger('click')
await flushPromises()
const dialog = wrapper.findComponent(AppDialog), form = wrapper.findComponent({ name: 'ElForm' }).props('model')
form.trackingNo = 'TRACK-1'; form.quantity = 3
dialog.vm.$emit('confirm'); await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2)
expect(vi.mocked(submitProcurementWorkflow).mock.calls[0]![3]).toEqual(vi.mocked(submitProcurementWorkflow).mock.calls[1]![3])
expect(form.trackingNo).toBe('TRACK-1'); expect(form.quantity).toBe(3)
form.quantity = 6; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2)
})
it('requires a rejection reason and preserves the current version and customer scope', async () => {
const wrapper = render('PENDING_REVIEW', ['review'])
await wrapper.findAll('button').find(button => button.text() === '审核')!.trigger('click'); await flushPromises()
const form = wrapper.findComponent({ name: 'ElForm' }).props('model'), dialog = wrapper.findComponent(AppDialog)
form.decision = 'REJECT'; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementCommand).not.toHaveBeenCalled()
form.reason = '商品无法采购'; dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-1', '1001', 'review', expect.objectContaining({ version: 4, decision: 'REJECT', reason: '商品无法采购', purchaseWarehouseId: undefined }))
})
it('only completes the selected delivering batch after signature evidence is supplied', async () => {
const wrapper = render('DELIVERING')
await wrapper.setProps({ workflow: { ...workflow('DELIVERING'), outbounds: [{ id: '9007199254740993', outboundNo: 'OUT-1', status: 'DELIVERING', quantity: 3, trackingNo: 'TRACK-1', fileIds: [], createdAt: '', completedAt: null }] } })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '上传签收凭证')!.trigger('click'); await flushPromises()
const form = wrapper.findComponent({ name: 'ElForm' }).props('model'), dialog = wrapper.findComponent(AppDialog)
dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).not.toHaveBeenCalled()
vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '2', fileId: '9007199254740995', category: 'RECEIPT', fileName: 'signed.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' })
const input = document.querySelector('input[type="file"]') || wrapper.find('input[type="file"]').element
expect(input.getAttribute('accept')).toContain('application/pdf')
Object.defineProperty(input, 'files', { configurable: true, value: [new File(['signed'], 'signed.pdf', { type: 'application/pdf' })] })
input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
expect(uploadProcurementFile).toHaveBeenCalledWith('OP-1', '1001', 'RECEIPT', expect.any(File))
expect(form.fileIds).toEqual(['9007199254740995'])
dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'outbounds/9007199254740993/complete', { version: 4, idempotencyKey: expect.any(String), fileIds: ['9007199254740995'] })
})
})
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
import { submitProcurementWorkflow, type ProcurementWorkflow, type ProcurementOutbound, type ProcurementWorkflowReceipt } from '../api/procurementWorkflow'
import { dateTime, statusLabel } from '../procurementPresentation'
import type { PlatformProcurement } from '../procurementTypes'
import type { OverseasPurchaseWarehouseOption } from '../types'
const props = defineProps<{ detail: PlatformProcurement; workflow: ProcurementWorkflow; customerId: string; downloading?: string; beforeOutbound: () => Promise<void> }>()
const emit = defineEmits<{ changed: []; download: [id: string] }>()
const access = useAccessPreviewStore()
const open = ref(false), saving = ref(false), uploading = ref(false), checkingFees = ref(false), error = ref<ApiErrorDetails | null>(null)
const action = ref(''), batchId = ref(''), warehouses = ref<OverseasPurchaseWarehouseOption[]>([]), warehouseLoading = ref(false)
const form = reactive({ quantity: 1, trackingNo: '', fileIds: [] as string[], warehouseId: '', decision: 'APPROVE' as 'APPROVE' | 'REJECT', reason: '' })
const uploadedNames = ref<string[]>([])
const title = computed(() => ({ review: '审核采购单', receipts: '登记收货', 'request-confirmation': '采购完成', outbounds: '创建出库单', dispatch: '开始派送', complete: '上传签收凭证' })[action.value] || '采购操作')
const permission = computed(() => `platform:overseas-business:purchase:${action.value === 'review' ? 'review' : ['receipts', 'request-confirmation'].includes(action.value) ? 'receive-overseas' : 'ship-customer'}`)
const can = (name: string) => access.can(`platform:overseas-business:purchase:${name}`)
const permissionHint = computed(() => props.workflow.status === 'RECEIVING' && !can('receive-overseas')
? '当前账号未开通采购收货权限,请联系管理员开通。'
: ['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(props.workflow.status) && !can('ship-customer')
? '当前账号未开通采购出库及签收权限,请联系管理员开通。' : '')
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'procurement-workflow', label: title, isOpen: open, model: form, requiredPermission: permission })
let fingerprint = '', requestKey = '', openedScope = '', dialogGeneration = 0
const scope = () => `${props.customerId}:${props.workflow.orderNo}`
watch(scope, () => { dialogGeneration++; open.value = false; error.value = null })
watch(open, value => { if (!value) dialogGeneration++ })
async function begin(name: string, batch?: ProcurementOutbound) {
if (saving.value || uploading.value || checkingFees.value) return
const required = name === 'review' ? 'review' : ['receipts', 'request-confirmation'].includes(name) ? 'receive-overseas' : 'ship-customer'
if (!can(required)) return
const current = ++dialogGeneration
if (name === 'outbounds') {
checkingFees.value = true
try { await props.beforeOutbound() }
catch (failure) { if (current === dialogGeneration) ElMessage.warning(getApiErrorDetails(failure, '费用校验失败').message); return }
finally { checkingFees.value = false }
if (current !== dialogGeneration) return
}
action.value = name; batchId.value = batch?.id || ''; error.value = null; warehouseLoading.value = false
Object.assign(form, { quantity: 1, trackingNo: '', fileIds: [], warehouseId: '', decision: 'APPROVE', reason: '' }); uploadedNames.value = []
fingerprint = ''; requestKey = ''; openedScope = scope(); open.value = true; captureBaseline()
if (name === 'review') {
warehouseLoading.value = true
try { const result = await listOverseasPurchaseWarehouseOptions(props.detail.customerView.order.procurementDirection); if (current === dialogGeneration) warehouses.value = result }
catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '仓库加载失败') }
finally { if (current === dialogGeneration) warehouseLoading.value = false }
}
}
async function upload(event: Event) {
const input = event.target as HTMLInputElement; const files = Array.from(input.files || []); input.value = ''
if (!files.length || uploading.value) return
const current = dialogGeneration; const orderNo = props.workflow.orderNo; const customer = props.customerId; uploading.value = true; error.value = null
try {
for (const file of files) {
if (action.value === 'receipts' && !file.type.startsWith('image/')) throw new Error('收货凭证请选择图片')
const result = await uploadProcurementFile(orderNo, customer, 'RECEIPT', file)
if (current !== dialogGeneration) return
form.fileIds.push(result.fileId); uploadedNames.value.push(file.name)
}
} catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '上传失败') }
finally { uploading.value = false }
}
async function save() {
if (saving.value || uploading.value || openedScope !== scope() || !access.can(permission.value)) return
if (action.value === 'review' && form.decision === 'APPROVE' && !form.warehouseId) { ElMessage.warning('请选择收货仓'); return }
if (action.value === 'review' && form.decision === 'REJECT' && !form.reason.trim()) { ElMessage.warning('请填写拒绝原因'); return }
if (['receipts', 'outbounds'].includes(action.value) && (!Number.isSafeInteger(form.quantity) || form.quantity < 1)) { ElMessage.warning('请输入有效数量'); return }
if (action.value === 'receipts' && form.quantity > props.detail.customerView.order.purchaseQuantity - props.workflow.receivedQuantity) { ElMessage.warning('收货数量不能超过剩余采购数量'); return }
if (action.value === 'outbounds' && (!form.trackingNo.trim() || form.quantity > props.workflow.availableQuantity)) { ElMessage.warning('请填写物流跟踪号,数量不能超过可出库库存'); return }
if (['receipts', 'complete'].includes(action.value) && !form.fileIds.length) { ElMessage.warning(action.value === 'receipts' ? '请上传收货照片' : '请上传签收文件或图片'); return }
const data = { version: props.workflow.version, ...(action.value === 'receipts' ? { quantity: form.quantity, fileIds: [...form.fileIds] } : action.value === 'outbounds' ? { quantity: form.quantity, trackingNo: form.trackingNo.trim() } : action.value === 'complete' ? { fileIds: [...form.fileIds] } : {}) }
const next = JSON.stringify([action.value, batchId.value, form.warehouseId, form.decision, form.reason, data])
if (fingerprint !== next || !requestKey) { fingerprint = next; requestKey = crypto.randomUUID() }
const current = dialogGeneration
saving.value = true; error.value = null
try {
if (action.value === 'review') await submitProcurementCommand(props.workflow.orderNo, props.customerId, 'review', { version: props.workflow.version, idempotencyKey: requestKey, decision: form.decision, purchaseWarehouseId: form.decision === 'APPROVE' ? form.warehouseId : undefined, reason: form.reason.trim() || undefined })
else await submitProcurementWorkflow(props.workflow.orderNo, props.customerId, batchId.value ? `outbounds/${encodeURIComponent(batchId.value)}/${action.value}` : action.value, { ...data, idempotencyKey: requestKey })
if (current !== dialogGeneration) return
captureBaseline(); open.value = false; ElMessage.success('操作成功'); emit('changed')
} catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '操作失败') }
finally { saving.value = false }
}
const receiptColumns: DataTableColumn[] = [{ prop: 'quantity', label: '收货数量', width: 140 }, { key: 'receivedAt', label: '收货时间', minWidth: 170, formatter: (row: ProcurementWorkflowReceipt) => dateTime(row.receivedAt) }, { key: 'files', label: '照片', minWidth: 180, slotName: 'files' }]
const outboundColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, slotName: 'actions', fixed: 'left' }, { prop: 'outboundNo', label: '出库单号', minWidth: 190 },
{ prop: 'quantity', label: '出库数量', width: 110 }, { prop: 'trackingNo', label: '物流跟踪号', minWidth: 190 },
{ key: 'status', label: '状态', width: 120, formatter: (row: ProcurementOutbound) => statusLabel(row.status) },
{ key: 'createdAt', label: '创建时间', minWidth: 170, formatter: (row: ProcurementOutbound) => dateTime(row.createdAt) },
{ key: 'completedAt', label: '签收时间', minWidth: 170, formatter: (row: ProcurementOutbound) => dateTime(row.completedAt) },
{ key: 'files', label: '签收凭证', minWidth: 170, slotName: 'files' },
]
const fileName = (id: string) => props.detail.files.find(file => file.fileId === id)?.fileName || id
</script>
<template>
<section class="procurement-workflow">
<div class="density-actions">
<el-button v-if="workflow.status === 'PENDING_REVIEW' && can('review')" type="primary" @click="begin('review')">审核</el-button>
<template v-if="workflow.status === 'RECEIVING'"><el-button type="primary" :disabled="!can('receive-overseas') || workflow.receivedQuantity >= detail.customerView.order.purchaseQuantity" @click="begin('receipts')">登记收货</el-button><el-button :disabled="!can('receive-overseas') || !workflow.receivedQuantity" @click="begin('request-confirmation')">采购完成</el-button></template>
<el-button v-if="['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(workflow.status)" type="primary" :loading="checkingFees" :disabled="!can('ship-customer') || workflow.availableQuantity < 1" @click="begin('outbounds')">创建出库单</el-button>
</div>
<el-text v-if="permissionHint" type="warning" role="status">{{ permissionHint }}</el-text>
<dl class="density-fields inventory"><div><dt>累计收货</dt><dd>{{ workflow.receivedQuantity }}</dd></div><div><dt>采购库存</dt><dd>{{ workflow.stockQuantity }}</dd></div><div><dt>出库处理中</dt><dd>{{ workflow.reservedQuantity }}</dd></div><div><dt>可出库</dt><dd>{{ workflow.availableQuantity }}</dd></div></dl>
<h3>收货记录</h3><DataTable :data="workflow.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收货记录"><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<h3>出库单</h3><DataTable :data="workflow.outbounds" :columns="outboundColumns" row-key="id" empty-text="暂无出库单"><template #actions="{ row }"><el-button v-if="row.status === 'IN_TRANSIT'" :disabled="!can('ship-customer')" title="开始本批次派送" link type="primary" @click="begin('dispatch', row as ProcurementOutbound)">开始派送</el-button><el-button v-if="row.status === 'DELIVERING'" :disabled="!can('ship-customer')" title="上传图片或文件并确认本批次签收" link type="primary" @click="begin('complete', row as ProcurementOutbound)">上传签收凭证</el-button></template><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<AppDialog v-model="open" :title="title" width="580px" confirm-text="确认" :confirm-loading="saving" :confirm-disabled="uploading || warehouseLoading" @confirm="save">
<el-form :model="form" class="density-form" label-position="top" :disabled="saving">
<template v-if="action === 'review'"><el-form-item label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item><el-form-item v-if="form.decision === 'APPROVE'" label="收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehouseLoading"><el-option v-for="warehouse in warehouses" :key="warehouse.warehouseId" :value="warehouse.warehouseId" :label="warehouse.warehouseName" /></el-select></el-form-item><el-form-item v-else label="拒绝原因" required><el-input v-model="form.reason" type="textarea" :rows="2" maxlength="1000" /></el-form-item></template>
<el-form-item v-if="['receipts', 'outbounds'].includes(action)" :label="action === 'receipts' ? '本批收货数量' : '本批出库数量'" required><el-input-number v-model="form.quantity" :min="1" :max="action === 'outbounds' ? workflow.availableQuantity : detail.customerView.order.purchaseQuantity - workflow.receivedQuantity" :precision="0" /></el-form-item>
<el-form-item v-if="action === 'outbounds'" label="物流跟踪号" required><el-input v-model="form.trackingNo" maxlength="100" /></el-form-item>
<el-form-item v-if="['receipts', 'complete'].includes(action)" :label="action === 'receipts' ? '收货照片' : '签收文件或图片'" required><input type="file" multiple :accept="action === 'receipts' ? 'image/*' : 'image/*,application/pdf'" :disabled="saving || uploading" @change="upload" /><div v-for="name in uploadedNames" :key="name">{{ name }}</div></el-form-item>
<p v-if="action === 'request-confirmation'">确认采购完成并提交客户确认?</p><p v-if="action === 'dispatch'">确认本批次开始派送?</p>
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
</AppDialog>
</section>
</template>
<style scoped>
.inventory { margin: var(--admin-space-4) 0; }
h3 { margin: var(--admin-space-4) 0 var(--admin-space-3); font-size: var(--admin-font-section); }
</style>
@@ -31,14 +31,14 @@ defineExpose({
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
</el-form-item>
<el-form-item label="起运地" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
<el-form-item label="起运地联系人" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" /></el-form-item>
<el-form-item label="目的地联系人" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" /></el-form-item>
<el-form-item label="起运地联系电话" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" /></el-form-item>
<el-form-item label="目的地联系电话" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></el-form-item>
<el-form-item label="起运详细地址"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
<el-form-item label="目的详细地址"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点取货地' : '起运地'" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点联系人' : '起运地联系人'" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点联系电话' : '起运地联系电话'" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点联系电话' : '目的地联系电话'" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点取货地址' : '起运详细地址'"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点送达地址' : '目的详细地址'"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
</div>
</FormSection>
<FormSection class="density-form-section" title="货物信息">
@@ -58,8 +58,8 @@ defineExpose({
<el-select v-model="model.currency" aria-label="预算币种" placeholder="请选择预算币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select>
</div></el-form-item>
<div />
<el-form-item label="取货时间" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
<el-form-item label="送货时间" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点取货时间' : '取货时间'" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点送达时间' : '送货时间'" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</div>
</FormSection>
@@ -11,6 +11,7 @@ import { executeUsTruckCommand } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import { availableTruckCommands, truckCommandHints, truckCommandLabels, type TruckCommand, type TruckOperation } from '../truckWorkflow'
import type { LocalTransportDetail } from '../types'
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses/api/platformWarehouses'
const props = defineProps<{ detail: LocalTransportDetail }>()
const emit = defineEmits<{ updated: [detail: LocalTransportDetail]; busy: [value: boolean] }>()
@@ -20,7 +21,8 @@ const open = ref(false), saving = ref(false)
const active = ref<TruckCommand | null>(null)
const error = ref<ApiErrorDetails | null>(null), validationError = ref('')
const pending = ref<TruckOperation | null>(null)
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' })
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' })
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const selectablePallets = computed(() => props.detail.pallets.filter(pallet => pallet.status === 'RECEIVED'))
const review = computed(() => active.value === 'review' || active.value === 'review-pallet-overage')
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'truck-order-command', label: computed(() => active.value ? truckCommandLabels[active.value] : '卡车单据操作'), isOpen: computed(() => open.value || !!pending.value), model: reactive({ form, pending }), requiredPermission: computed(() => overseasBusinessPermissions.truckStatus) })
@@ -30,25 +32,33 @@ watch(() => [props.detail.customerId, props.detail.orderNo, props.detail.version
pending.value = null; open.value = false; active.value = null; error.value = null
})
function start(command: TruckCommand) {
async function start(command: TruckCommand) {
if (saving.value || !commands.value.includes(command)) return
if (pending.value) {
active.value = pending.value.command; open.value = true
return
}
active.value = command; error.value = null; validationError.value = ''
Object.assign(form, { decision: 'APPROVE', actualPalletCount: Math.max(1, props.detail.receivedPalletCount), packageCount: undefined, palletIds: [], remark: '' })
Object.assign(form, { decision: 'APPROVE', warehouseId: '', actualPalletCount: Math.max(1, props.detail.receivedPalletCount), packageCount: undefined, palletIds: [], remark: '' })
if (command === 'review' || command === 'arrange') {
try { warehouses.value = (await listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' })).records } catch { warehouses.value = [] }
}
open.value = true; captureBaseline()
}
function buildOperation(): TruckOperation | null {
if (!active.value) return null
const base = { version: props.detail.version, idempotencyKey: globalThis.crypto?.randomUUID?.() || `truck-${Date.now()}-${Math.random().toString(36).slice(2)}`, remark: form.remark.trim() || undefined }
if ((active.value === 'review' && form.decision === 'APPROVE') || active.value === 'arrange') {
if (!form.warehouseId) { validationError.value = '请选择 A 海外仓'; return null }
}
if ((active.value === 'cancel' || (review.value && form.decision === 'REJECT')) && !base.remark) {
validationError.value = '请填写拒绝或取消原因'; return null
}
switch (active.value) {
case 'review': case 'review-pallet-overage': return { command: active.value, payload: { ...base, decision: form.decision } }
case 'review': return { command: active.value, payload: { ...base, decision: form.decision, ...(form.decision === 'APPROVE' ? { warehouseId: form.warehouseId } : {}) } }
case 'arrange': return { command: active.value, payload: { ...base, warehouseId: form.warehouseId } }
case 'review-pallet-overage': return { command: active.value, payload: { ...base, decision: form.decision } }
case 'receive-pallets':
if (!Number.isSafeInteger(form.actualPalletCount) || form.actualPalletCount < Math.max(1, props.detail.receivedPalletCount)) {
validationError.value = '累计实收托数须为正整数,且不能少于已实收托数'; return null
@@ -70,7 +80,9 @@ function buildOperation(): TruckOperation | null {
async function submit() {
if (saving.value || !active.value || !commands.value.includes(active.value)) return
validationError.value = ''; error.value = null
const operation = buildOperation()
// Keep an operation immutable after an uncertain response so a retry cannot
// accidentally use a different version, idempotency key, or edited payload.
const operation = pending.value || buildOperation()
if (!operation) return
pending.value = operation; saving.value = true
const customerId = props.detail.customerId, orderNo = props.detail.orderNo
@@ -82,7 +94,7 @@ async function submit() {
emit('updated', updated)
} catch (cause) {
if (props.detail.customerId === customerId && props.detail.orderNo === orderNo) error.value = getApiErrorDetails(cause, '操作未确认成功,请重试或刷新订单核对最新记录')
} finally { pending.value = null; saving.value = false }
} finally { saving.value = false }
}
</script>
@@ -98,6 +110,7 @@ async function submit() {
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
<el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit">
<el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="active === 'arrange' || (active === 'review' && form.decision === 'APPROVE')" label="调度仓库" required><el-select v-model="form.warehouseId" placeholder="选择 A 海外仓" filterable><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<template v-if="active === 'receive-pallets'">
<el-form-item label="累计实收托数" required><el-input-number v-model="form.actualPalletCount" :min="Math.max(1, detail.receivedPalletCount)" :precision="0" /></el-form-item>
<el-form-item label="实收件数"><el-input-number v-model="form.packageCount" :min="0" :precision="0" /></el-form-item>
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants } from '@/modules/iam'
import type { PlatformProcurement } from '../procurementTypes'
import { downloadProcurementFile } from '../api/procurementCommands'
import { getProcurementWorkflow } from '../api/procurementWorkflow'
import type { FleetArrangement } from '../fleetArrangement'
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder } from '../types'
import {
@@ -30,6 +31,7 @@ const purchaseDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/ov
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/procurementWorkflow', () => ({ getProcurementWorkflow: vi.fn(), submitProcurementWorkflow: vi.fn() }))
vi.mock('../api/procurementCommands', async importOriginal => ({
...await importOriginal<typeof import('../api/procurementCommands')>(),
downloadProcurementFile: vi.fn(),
@@ -46,7 +48,7 @@ function memoryStorage(): Storage { const values = new Map<string, string>(); re
const purchase = {
id: '9007199254740993', orderNo: 'OP-100', customerId: '10001', customerName: '客户甲', purchaseCategory: '家居', productName: '置物架', productSpecification: '黑色', productUrl: 'https://example.test/product',
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', status: 'WAIT_PACKING' as const, version: 3,
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', status: 'RECEIVING' as const, version: 3,
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086',
estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
histories: [{ operationType: 'UPDATE', fromStatus: 'WAIT_PURCHASE', toStatus: 'WAIT_PURCHASE', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [],
@@ -70,6 +72,7 @@ describe('overseas business mounted pages', () => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = ''
routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
vi.mocked(downloadProcurementFile).mockReset()
vi.mocked(getProcurementWorkflow).mockReset().mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] })
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(procurementDetail()); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 })
vi.mocked(getOverseasPurchaseEditor).mockReset().mockResolvedValue({ customerView: { order: { orderNo: purchase.orderNo, customerId: purchase.customerId, productName: purchase.productName, purchaseQuantity: purchase.purchaseQuantity, version: purchase.version } }, internalRemark: purchase.remark })
vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }])
@@ -161,12 +164,13 @@ describe('overseas business mounted pages', () => {
const wrapper = mountPage(PurchaseOrderDetailView)
await flushPromises()
expect(wrapper.get('.density-detail-title').text()).toBe('OP-100')
expect(wrapper.get('[aria-label="采购订单概览"]').text()).toContain('洛杉矶采购仓')
expect(wrapper.findAll('.density-fields')).toHaveLength(3)
expect(wrapper.text()).toContain('洛杉矶采购仓')
expect(wrapper.findAll('.density-fields')).toHaveLength(2)
expect(wrapper.text()).toContain('预算单价')
expect(wrapper.text()).toContain('两段实际交寄')
expect(wrapper.text()).not.toContain('库存信息')
expect(wrapper.findAll('.density-fields .wide')).toHaveLength(2)
expect(wrapper.text()).toContain('收货记录')
expect(wrapper.text()).toContain('出库单')
expect(wrapper.text()).toContain('采购库存')
expect(wrapper.text()).not.toContain('两段实际交寄')
expect(wrapper.text()).not.toContain('internal-purchase-route')
await wrapper.get('.density-back').trigger('click')
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchases' })
@@ -177,12 +181,13 @@ describe('overseas business mounted pages', () => {
const result = procurementDetail()
result.files = [{ id: '9007199254741000', fileId: '9007199254741001', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 123, createdAt: '2026-10-10T10:00:00' }]
vi.mocked(getOverseasPurchase).mockResolvedValue(result)
vi.mocked(getProcurementWorkflow).mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [{ id: '1', quantity: 5, fileIds: ['9007199254741001'], receivedAt: '2026-10-10T10:00:00' }], outbounds: [] })
let rejectDownload!: (error: Error) => void
vi.mocked(downloadProcurementFile).mockImplementation(() => new Promise((_resolve, reject) => { rejectDownload = reject }))
const wrapper = mountPage(PurchaseOrderDetailView)
useAuthStore().permissions = ['platform:overseas-business:purchase:list']
await flushPromises()
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text() === '下载')
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text() === 'label.pdf')
expect(button).toBeDefined()
await button!.trigger('click'); await flushPromises()
expect(downloadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', '9007199254741001')
@@ -194,18 +199,14 @@ describe('overseas business mounted pages', () => {
expect(document.body.textContent).toContain('文件下载失败')
})
it('renders estimate holds separately from actual payment without claiming inbound settlement', async () => {
it('uses the shared overseas fees panel for the exact procurement customer and document', async () => {
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
const result = procurementDetail()
result.customerView.fees = [{ feeNo: 'FEE-1', purpose: 'INTERNATIONAL_ESTIMATE', name: '国际运费预估', version: 1, active: true, determination: 'POSITIVE', amount: '25.00', currency: 'CNY', originalAmount: '25.00', originalCurrency: 'CNY', exchangeRate: '1', exchangeRateMonth: '2026-10', businessDate: '2026-10-09', parentFeeNo: null, walletStatus: 'FROZEN', sourceLinked: false }]
vi.mocked(getOverseasPurchase).mockResolvedValue(result)
const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises()
expect(wrapper.text()).toContain('已冻结,未扣款')
expect(wrapper.text()).toContain('预估不入池')
expect(wrapper.text()).not.toContain('订单全部入库后结算')
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).props()).toEqual(expect.objectContaining({ businessType: 'OVERSEAS_PROCUREMENT', businessNo: 'OP-100', customerId: '10001', businessId: '9007199254740993' }))
expect(wrapper.text()).not.toContain('预估不入池')
})
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待装箱'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('收货中'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it('keeps long procurement identifiers and descriptive fields out of single-line truncation', async () => {
const record = { ...purchase, orderNo: 'QLYSXDKH202610100002', customerName: '全量验收下单客户-20261010-长客户名称', productName: 'PROC-PURCHASE-20261010-长商品名称', productSpecification: '多规格描述-完整展示', purchaseCategory: 'OFFICE_SUPPLIES', purchaseWarehouseName: '洛杉矶海外采购收货仓库' }
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [record], page: 1, size: 20, total: 1, pages: 1 })
@@ -223,7 +224,7 @@ describe('overseas business mounted pages', () => {
it('refreshes the cached procurement detail after an edit completes', () => { expect(purchaseDetailSource).toContain('onActivated'); expect(purchaseDetailSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) })
it('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') })
it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') })
it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('收货记录'); expect(wrapper.text()).toContain('出库单'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') })
it('submits only the internal remark and preserves input after a version conflict', async () => {
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
@@ -276,10 +277,10 @@ describe('overseas business mounted pages', () => {
expect(model.totalWeightKg).toBe(0); expect(model.totalVolumeCbm).toBe(0)
expect(model.budgetAmount).toBeUndefined(); expect(model.currency).toBe('USD')
const labels = wrapper.findAll('.el-form-item__label').map(item => item.text())
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual([
'客户', ...(domain === 'truck' ? ['车辆类型'] : []), '起运地', '目的地',
'起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址',
])
const routeLabels = domain === 'truck'
? ['B点取货地', 'C点送达地', 'B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话', 'B点取货地址', 'C点送达地址']
: ['起运地', '目的地', '起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址']
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual(['客户', ...(domain === 'truck' ? ['车辆类型'] : []), ...routeLabels])
Object.assign(model, { vehicleType: 'PALLET_15', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: 0, currency: 'CNY' })
const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')!
await submit.trigger('click'); await flushPromises()
@@ -307,7 +308,7 @@ describe('overseas business mounted pages', () => {
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) })
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, estimatedPalletCount: 2 })) })
it('requires split contacts and both local times, rejecting reversed times', async () => {
@@ -133,7 +133,7 @@ onMounted(() => { void load(); void loadContext() })
<template>
<AdminPage class="admin-density admin-workbench">
<ListPageLayout aria-label="海外费用管理">
<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">
@@ -1,6 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, onActivated, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -9,7 +8,6 @@ import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -17,136 +15,60 @@ import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getOverseasPurchase } from '../api/platformOverseasBusiness'
import { downloadProcurementFile } from '../api/procurementCommands'
import ProcurementNodeActions from '../components/ProcurementNodeActions.vue'
import { remainingLabels, decisionLabels } from '../procurementControlForm'
import { getProcurementWorkflow, type ProcurementWorkflow } from '../api/procurementWorkflow'
import ProcurementWorkflowPanel from '../components/ProcurementWorkflowPanel.vue'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { overseasBusinessPermissions } from '../permissions'
import { statusLabel, money, dateTime, text, measurement, feeStatus, feePurposes } from '../procurementPresentation'
import type { PlatformProcurement, ProcurementFile, ProcurementFee, ProcurementReceipt, ProcurementCarton, ProcurementShipment } from '../procurementTypes'
import { directionLabel, regionText } from '../procurementDirection'
import { statusLabel, money, dateTime, text } from '../procurementPresentation'
import type { PlatformProcurement } from '../procurementTypes'
import { directionLabel } from '../procurementDirection'
import { requireReviewedProcurementFees } from '../procurementOutboundFees'
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const access = useAccessPreviewStore()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const detail = ref<PlatformProcurement | null>(null); const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const operationError = ref<ApiErrorDetails | null>(null)
const downloading = ref<string | null>(null)
const detail = ref<PlatformProcurement | null>(null), workflow = ref<ProcurementWorkflow | null>(null), loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null), downloading = ref('')
const order = computed(() => detail.value?.customerView.order)
const t = (text: string) => regionText(text, order.value?.procurementDirection)
const feePanel = ref<InstanceType<typeof BusinessDocumentFees>>()
async function beforeOutbound() {
if (!feePanel.value) throw new Error('费用明细尚未加载完成,请稍后重试')
requireReviewedProcurementFees(await feePanel.value.getFees())
}
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)],
[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(() => [
['实际购买时间', dateTime(detail.value?.purchaseFacts.purchased_at)], ['实购商品', detail.value?.purchaseFacts.purchased_product_name],
['实购数量', detail.value?.purchaseFacts.purchased_quantity], ['原登记实付总额', money(detail.value?.purchaseFacts.purchase_amount, detail.value?.purchaseFacts.purchase_currency)],
['货款核定时间', dateTime(detail.value?.purchaseFacts.purchase_confirmed_at)], ['待处理事项', detail.value?.purchaseFacts.purchase_issue],
] as const)
const receiptColumns: DataTableColumn[] = [
{ key: 'receiptNo', prop: 'receiptNo', label: '实收编号', minWidth: 180 },
{ key: 'leg', label: '收货段', width: 100, formatter: (row: ProcurementReceipt) => 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]) => 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 },
{ key: 'active', label: '状态', width: 110, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => row.active ? '有效' : '已撤销或更正' },
]
const cartonColumns: DataTableColumn[] = [
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 160 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 },
{ key: 'measurements', label: '长 × 宽 × 高(cm)', minWidth: 220, formatter: (row: ProcurementCarton) => [row.lengthCm, row.widthCm, row.heightCm].map(value => measurement(value, 2)).join(' × ') },
{ key: 'weight', label: '毛重(kg)', width: 120, formatter: (row: ProcurementCarton) => measurement(row.grossWeightKg, 3) },
{ key: 'sources', label: '装货来源', minWidth: 240, formatter: (row: ProcurementCarton) => row.sources.map(source => `${detail.value?.physical.receipts.find(receipt => receipt.id === source.receiptId)?.receiptNo || '未关联实收'}:${source.quantity} 件`).join(';') },
]
const shipmentColumns: DataTableColumn[] = [
{ key: 'shipmentNo', prop: 'shipmentNo', label: '交寄编号', minWidth: 180 }, { key: 'leg', label: '运输段', width: 110, formatter: (row: ProcurementShipment) => row.leg === 'OVERSEAS' ? '国际交寄' : '寄客户' },
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 150 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 100 }, { key: 'trackingNo', prop: 'trackingNo', label: '实际运单', minWidth: 180 },
{ key: 'shippedAt', label: '交寄时间', minWidth: 160, formatter: (row: ProcurementShipment) => dateTime(row.shippedAt) },
]
const feeColumns: DataTableColumn[] = [
{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'purpose', label: '用途', minWidth: 140, formatter: (row: ProcurementFee) => 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)}` },
{ key: 'walletStatus', label: '资金状态', minWidth: 150, formatter: (row: ProcurementFee) => feeStatus(row) },
{ key: 'sourceLinked', label: '费用来源关联', width: 130, formatter: (row: ProcurementFee) => row.purpose === 'INTERNATIONAL_ESTIMATE' ? '预估不入池' : row.purpose === 'REFUND' ? '退款不入应收' : ['ZERO', 'NOT_APPLICABLE'].includes(row.determination) ? '无需入池' : row.sourceLinked ? '已完成' : '待完成' },
{ key: 'active', label: '版本状态', width: 100, formatter: (row: ProcurementFee) => row.active ? '有效' : '历史版本' },
]
const labelGroups = computed(() => detail.value ? [
{ leg: 'OVERSEAS', title: '国际段', value: detail.value.transport.overseasLabels },
{ leg: 'DOMESTIC', title: '寄客户段', value: detail.value.transport.domesticLabels },
].filter(group => group.value) : [])
const labelColumns: DataTableColumn[] = [
{ key: 'cartonId', label: '原箱', minWidth: 150, formatter: (row: { cartonId: string }) => detail.value?.physical.cartons.find(box => box.id === row.cartonId)?.cartonNo || '未关联箱' },
{ key: 'fileId', label: '面单文件', minWidth: 200, formatter: (row: { fileId: string }) => detail.value?.files.find(file => file.fileId === row.fileId)?.fileName || '未关联文件' },
{ key: 'page', prop: 'page', label: '页码', width: 75 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 120 }, { key: 'trackingNo', prop: 'trackingNo', label: '运单号', minWidth: 180 },
{ key: 'appliedAt', label: '实际贴单时间', minWidth: 160, formatter: (row: { appliedAt: string | null }) => dateTime(row.appliedAt) },
]
const fileLabels: Record<string, string> = { PURCHASE: '购买凭证', BUDGET: '预算授权', LABEL: '快递面单', EXCEPTION: '异常证据', RECEIPT: '收货凭证', VOUCHER: '费用凭证' }
const noteColumns: DataTableColumn[] = [
{ key: 'note_no', prop: 'note_no', label: '说明编号', minWidth: 180 },
{ key: 'target', label: '关联事实', minWidth: 180, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.target_type === 'PURCHASE' ? order.value?.orderNo || '—' : row.target_type === 'WAREHOUSE' ? row.original_value_json?.name || '实际仓记录' : row.target_type === 'RECEIPT' ? detail.value?.physical.receipts.find(item => item.id === row.target_id)?.receiptNo || '未关联实收' : detail.value?.transport.shipments.find(item => item.id === row.target_id)?.shipmentNo || '未关联交寄' },
{ key: 'reason', prop: 'reason', label: '内部事实说明', minWidth: 260 },
{ key: 'warehouseCorrection', label: '仓库更正留痕', minWidth: 190, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.corrected_value_json ? `${row.original_value_json?.name || '—'} → ${row.corrected_value_json.warehouseName || '—'}` : '—' },
{ key: 'block', label: '阻断状态', width: 140, formatter: (row: PlatformProcurement['controls']['notes'][number]) => !row.blocking ? '仅说明' : row.resolved_at ? '已解除阻断' : '待处理阻断' },
{ key: 'resolution_reason', prop: 'resolution_reason', label: '解除依据', minWidth: 220 },
{ key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 220 },
]
const remainingColumns: DataTableColumn[] = [{ key: 'businessNo', prop: 'businessNo', label: '货物来源', minWidth: 180 }, { key: 'kind', label: '剩余类型', minWidth: 130, formatter: (row: PlatformProcurement['controls']['remaining'][number]) => remainingLabels[row.kind] }, { key: 'quantity', prop: 'quantity', label: '待处理件数', width: 130 }]
const closureColumns: DataTableColumn[] = [{ key: 'closure_no', prop: 'closure_no', label: '关闭编号', minWidth: 180 }, { key: 'financial_conclusion', prop: 'financial_conclusion', label: '内部财务结论', minWidth: 250 }, { key: 'reason', prop: 'reason', label: '内部关闭原因', minWidth: 220 }, { key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 250 }, { key: 'created_at', label: '关闭时间', minWidth: 170, formatter: (row: PlatformProcurement['controls']['closures'][number]) => dateTime(row.created_at) }]
const closureItemColumns: DataTableColumn[] = [
{ key: 'source', label: '处置来源', minWidth: 180, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => row.source_kind === 'PURCHASE_REMAINDER' ? order.value?.orderNo || '—' : row.source_kind === 'TRANSIT_REMAINDER' ? detail.value?.physical.cartons.find(item => item.id === row.source_id)?.cartonNo || '未关联原箱' : detail.value?.physical.receipts.find(item => item.id === row.source_id)?.receiptNo || '未关联实收' },
{ key: 'source_kind', label: '来源类型', width: 130, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => remainingLabels[row.source_kind] },
{ key: 'quantity', prop: 'quantity', label: '件数', width: 90 }, { key: 'decision', label: '实际处置', minWidth: 150, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => decisionLabels[row.decision] || '待核对' }, { key: 'destination', prop: 'destination', label: '实际去向与接收方', minWidth: 260 },
]
const correctionColumns: DataTableColumn[] = [
{ key: 'correctionNo', prop: 'correctionNo', label: '更正编号', minWidth: 190 }, { key: 'rootFeeNo', prop: 'rootFeeNo', label: '原费用编号', minWidth: 170 },
{ key: 'targetOriginalAmount', label: '正确原币总额', minWidth: 150, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetOriginalAmount, row.originalCurrency) },
{ key: 'targetAmount', label: '目标人民币', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetAmount, 'CNY') },
{ key: 'previousNetAmount', label: '更正前净实扣', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.previousNetAmount, 'CNY') },
{ key: 'differenceAmount', label: '本次差额', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.differenceAmount, 'CNY') },
{ key: 'feeNos', label: '关联实际费用或调整', minWidth: 240, formatter: (row: PlatformProcurement['feeCorrections'][number]) => row.feeNos.join(';') || '无资金差额' },
]
const fileColumns: DataTableColumn[] = [
{ key: 'actions', slotName: 'actions', label: '操作', width: 90, fixed: 'left' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 250 },
{ key: 'category', label: '用途', width: 120, formatter: (row: ProcurementFile) => fileLabels[row.category] || '待核对' },
{ key: 'createdAt', label: '上传时间', minWidth: 160, formatter: (row: ProcurementFile) => dateTime(row.createdAt) },
]
const historyColumns: DataTableColumn[] = [{ key: 'createdAt', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['reviewHistory'][number]) => dateTime(row.createdAt) }, { key: 'operation', label: '操作', width: 120, formatter: (row: PlatformProcurement['reviewHistory'][number]) => ({ CREATE: '创建', REVIEW: '审核', UPDATE: '编辑备注' } as Record<string, string>)[row.operationType || ''] || '采购变更' }, { key: 'remark', prop: 'remark', label: '说明', minWidth: 250 }]
const commandColumns: DataTableColumn[] = [{ key: 'created_at', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['operations'][number]) => dateTime(row.created_at) }, { key: 'reason', prop: 'reason', label: '履约操作说明', minWidth: 400 }]
let generation = 0; let inFlight: Promise<void> | null = null; let inFlightScope = ''
['客户', order.value?.customerName], ['采购方向', directionLabel(order.value?.procurementDirection)], ['收货仓', order.value?.actualWarehouseName || order.value?.plannedWarehouseName],
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['规格', order.value?.productSpecification],
['采购数量', order.value?.purchaseQuantity], ['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['预计到仓', dateTime(order.value?.estimatedArrivalAt)],
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['收货地址', order.value?.receivingAddress],
...(detail.value?.internalRemark ? [['内部备注', detail.value.internalRemark]] : []),
])
let generation = 0, inFlight: Promise<void> | null = null, inFlightScope = ''
const scope = computed(() => `${auth.user?.id || ''}:${customerId.value}:${purchaseId.value}`)
onBeforeUnmount(() => { generation++ })
function load() {
const currentScope = scope.value
if (inFlight && inFlightScope === currentScope) return inFlight
const current = ++generation; const number = purchaseId.value; const customer = customerId.value
detail.value = null; loadError.value = null; loading.value = false
detail.value = null; workflow.value = null; loadError.value = null; loading.value = false
if (!number || !customer) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return Promise.resolve() }
loading.value = true; inFlightScope = currentScope
const promise = getOverseasPurchase(number, customer).then(result => { if (current === generation) detail.value = result }).catch(error => { if (current === generation) loadError.value = getApiErrorDetails(error, '采购详情加载失败') }).finally(() => {
const promise = Promise.all([getOverseasPurchase(number, customer), getProcurementWorkflow(number, customer)]).then(([result, process]) => {
if (current === generation) { detail.value = result; workflow.value = process }
}).catch(error => { if (current === generation) loadError.value = getApiErrorDetails(error, '采购详情加载失败') }).finally(() => {
if (current === generation) loading.value = false
if (inFlight === promise) inFlight = null
})
inFlight = promise; return promise
}
async function download(fileId: unknown) {
async function download(fileId: string) {
if (!order.value || downloading.value) return
const file = detail.value?.files.find(item => item.fileId === fileId)
if (!file || !order.value || downloading.value) return
downloading.value = file.fileId; operationError.value = null; const current = scope.value
downloading.value = fileId; operationError.value = null; const current = scope.value
try {
const blob = await downloadProcurementFile(order.value.orderNo, customerId.value, file.fileId)
const blob = await downloadProcurementFile(order.value.orderNo, customerId.value, fileId)
if (scope.value !== current) return
const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file?.fileName || fileId; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (error) { if (scope.value === current) operationError.value = getApiErrorDetails(error, '文件下载失败') }
finally { downloading.value = null }
finally { downloading.value = '' }
}
let activatedOnce = false
watch(scope, () => { void load() }, { immediate: true })
@@ -154,29 +76,14 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
</script>
<template>
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="operationError" />
<DetailPageLayout class="purchase-detail density-detail-layout workbench-panel is-flush is-page-surface" aria-label="采购订单履约详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="order" :status="statusLabel(order.status, 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>
<DetailPageLayout class="purchase-detail density-detail-layout workbench-panel is-flush is-page-surface" aria-label="采购订单详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="workflow" :status="statusLabel(workflow.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑备注</el-button></div></div></template>
<LoadErrorState v-if="loadError" title="采购详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="detail-grid">
<template v-if="detail">
<ProcurementNodeActions :detail="detail" :customer-id="customerId" @changed="load" />
<section><h3>订单及预算</h3><dl class="density-fields"><div v-for="field in fields" :key="field[0]" :class="{ wide: ['下单收货地址', '内部备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
<section><h3>购买事实</h3><dl class="density-fields"><div v-for="field in purchaseFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
<section><h3>履约进度</h3><dl class="density-fields"><div v-for="step in detail.customerView.progress" :key="step.key"><dt>{{ step.title }}</dt><dd>{{ step.completedAt ? dateTime(step.completedAt) : '未完成' }}</dd></div></dl></section>
<section><h3>分次实收记录</h3><div class="workbench-table"><DataTable :data="detail.physical.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无实收记录" /></div></section>
<section><h3>差异处置记录</h3><div class="workbench-table"><DataTable :data="detail.physical.dispositions" :columns="dispositionColumns" row-key="id" empty-text="暂无差异处置" /></div></section>
<section><h3>逐箱装货清单 · {{ detail.physical.packingStatus === 'CONFIRMED' ? '已确认' : detail.physical.packingStatus === 'DRAFT' ? '草稿' : '未建立' }}<span v-if="detail.physical.packingVersion">(第 {{ detail.physical.packingVersion }} 版)</span></h3><div class="workbench-table"><DataTable :data="detail.physical.cartons" :columns="cartonColumns" row-key="id" empty-text="暂无箱明细" /></div></section>
<section v-if="detail.transport.domesticCargo.length"><h3>{{ 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>
<section><h3>费用更正记录<InlineHelp title="费用更正说明"><p>本表记录核定目标、差额及未实扣费用的替代版本;实际扣款、退款结果见费用与资金明细。</p></InlineHelp></h3><div class="workbench-table"><DataTable :data="detail.feeCorrections" :columns="correctionColumns" row-key="correctionNo" empty-text="暂无费用目标更正" /></div></section>
<section><h3>事实说明与阻断</h3><div class="workbench-table"><DataTable :data="detail.controls.notes" :columns="noteColumns" row-key="id" empty-text="暂无事实说明或阻断" /></div></section>
<section><h3>剩余货物去向核对</h3><div class="workbench-table"><DataTable :data="detail.controls.remaining" :columns="remainingColumns" empty-text="当前无待处置的剩余货物" /></div></section>
<section v-if="detail.controls.closures.length"><h3>异常关闭记录</h3><div class="workbench-table"><DataTable :data="detail.controls.closures" :columns="closureColumns" row-key="id" /></div><div class="workbench-table"><DataTable :data="detail.controls.closureItems" :columns="closureItemColumns" row-key="id" empty-text="关闭时无剩余实物" /></div></section>
<section><h3>本单文件</h3><div class="workbench-table"><DataTable :data="detail.files" :columns="fileColumns" row-key="fileId" empty-text="暂无文件"><template #actions="{ row }"><el-button class="density-action" :loading="downloading === row.fileId" :disabled="!!downloading" @click="download(row.fileId)">下载</el-button></template></DataTable></div></section>
<section><h3>操作流水</h3><div class="workbench-table"><DataTable :data="detail.reviewHistory" :columns="historyColumns" empty-text="暂无基础操作记录" /></div><div class="workbench-table"><DataTable :data="detail.operations" :columns="commandColumns" empty-text="暂无履约操作记录" /></div></section>
<template v-if="detail && order && workflow">
<section><h3>采购信息</h3><dl class="density-fields"><div v-for="field in fields" :key="String(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
<ProcurementWorkflowPanel :detail="detail" :workflow="workflow" :customer-id="customerId" :downloading="downloading" :before-outbound="beforeOutbound" @changed="load" @download="download" />
<BusinessDocumentFees ref="feePanel" business-type="OVERSEAS_PROCUREMENT" :business-no="order.orderNo" :customer-id="customerId" :business-id="order.id" />
</template>
</div>
</DetailPageLayout>
@@ -186,6 +93,4 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
.detail-grid { display: grid; min-width: 0; gap: var(--admin-space-5); }
.detail-grid section { min-width: 0; }
.detail-grid h3 { margin: 0 0 var(--admin-space-3); font-size: var(--admin-font-section); }
.detail-grid p { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.density-fields > .wide { grid-column: span 2; }
</style>
@@ -28,10 +28,10 @@ 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: ({ WAIT_OVERSEAS_RECEIPT: '待起运仓收货', OVERSEAS_RECEIVING: '起运地收货中', WAIT_OVERSEAS_SHIPMENT: '待国际交寄', DOMESTIC_RECEIVING: '目的地收货中', WAIT_CHARGES: '待目的地核费' } as Record<string, string>)[value] || label })) },
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 120, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 220, singleLine: false },
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 220, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
@@ -76,7 +76,7 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单">
<template #toolbar-start><h1>采购订单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="search" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
@@ -35,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, BusinessDocumentFees: true } } })
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true, BusinessDocumentFees: true, ElSelect: true, ElOption: true } } })
}
async function click(wrapper: VueWrapper, label: string) {
const button = wrapper.findAll('button').find(item => item.text() === label)
@@ -43,10 +43,14 @@ async function click(wrapper: VueWrapper, label: string) {
await button!.trigger('click'); await flushPromises()
}
async function confirm(wrapper: VueWrapper) {
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
const form = wrapper.findComponent(TruckOrderActions).findComponent({ name: 'ElForm' })
if (form.exists() && form.props('model').decision === 'APPROVE') form.props('model').warehouseId = '9'
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
}
const formModel = (wrapper: VueWrapper) => wrapper.findComponent(TruckOrderActions).findComponent({ name: 'ElForm' }).props('model')
vi.mock('@/modules/warehouses/api/platformWarehouses', () => ({ listPlatformWarehouses: vi.fn().mockResolvedValue({ records: [{ id: '9', warehouseCode: 'WH-9', warehouseName: '海外仓' }] }) }))
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.clearAllMocks()
@@ -78,21 +82,12 @@ describe('truck review and fulfillment', () => {
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-trucks' })
})
it('loads customer scoped documents and runs review through receipt, allocation and delivery completion', async () => {
it('loads customer scoped documents and moves approval directly to dispatch', async () => {
let current = order()
vi.mocked(executeUsTruckCommand).mockImplementation(async (_no, _customer, operation) => {
const next = { ...current, version: current.version + 1 }
switch (operation.command) {
case 'review': next.status = 'WAITING_INBOUND'; break
case 'receive-pallets':
next.status = 'WAITING_CONSOLIDATION'; next.palletCount = 2; next.receivedPalletCount = 2
next.pallets = [1, 2].map(sequenceNo => ({ id: `900719925474100${sequenceNo}`, sequenceNo, palletNo: `P-${sequenceNo}`, source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: '2026-10-20T10:00:00' }))
break
case 'allocate-pallets': next.status = 'ALLOCATED'; next.allocatedPalletCount = 2; next.pallets = next.pallets.map(row => ({ ...row, status: 'ALLOCATED' })); break
case 'prepare-dispatch': next.status = 'PENDING_DISPATCH'; break
case 'dispatch': next.status = 'DISPATCHED'; break
case 'start-transit': next.status = 'IN_TRANSIT'; break
case 'complete': next.status = 'COMPLETED'; break
case 'review': next.status = 'PENDING_DISPATCH'; break
}
next.histories = [...current.histories, { operationType: operation.command === 'review' ? 'REVIEW' : operation.command.toUpperCase().replaceAll('-', '_'), fromStatus: current.status, toStatus: next.status, operatorId: '操作员7', remark: operation.payload.remark || null, createdAt: '2026-10-20T10:00:00' }]
current = next; return next
@@ -100,79 +95,61 @@ describe('truck review and fulfillment', () => {
const wrapper = mountDetail(); await flushPromises()
expect(getUsTruckOrder).toHaveBeenCalledWith('TR-TEST', '9007199254741999')
expect(wrapper.text()).toContain('起运仓地址'); expect(wrapper.text()).toContain('审核与操作记录')
await click(wrapper, '审核'); formModel(wrapper).remark = '资料核对通过'; await confirm(wrapper)
expect(wrapper.text()).toContain('登记实收'); expect(wrapper.text()).toContain('资料核对通过')
await click(wrapper, '登记实收'); Object.assign(formModel(wrapper), { actualPalletCount: 2, packageCount: 20 }); await confirm(wrapper)
await click(wrapper, '分配托盘'); formModel(wrapper).palletIds = current.pallets.map(row => row.id); await confirm(wrapper)
for (const label of ['确认待派车', '确认已派车', '确认发运', '确认完成']) { await click(wrapper, label); await confirm(wrapper) }
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
expect(current.status).toBe('COMPLETED')
expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1, 2, 3, 4, 5, 6, 7])
expect(vi.mocked(executeUsTruckCommand).mock.calls[2][2]).toMatchObject({ command: 'allocate-pallets', payload: { palletIds: ['9007199254741001', '9007199254741002'] } })
await click(wrapper, '审核并生成排车'); formModel(wrapper).remark = '资料核对通过'; await confirm(wrapper)
expect(wrapper.text()).toContain('排车审核'); expect(wrapper.text()).toContain('资料核对通过')
expect(wrapper.text()).not.toContain('登记实收'); expect(wrapper.text()).not.toContain('分配托盘')
expect(current.status).toBe('PENDING_DISPATCH')
expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1])
})
it('requires a rejection reason and displays the resulting rejected state', async () => {
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核')
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车')
formModel(wrapper).decision = 'REJECT'; await confirm(wrapper)
expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请填写拒绝或取消原因')
formModel(wrapper).remark = '取送时间需重新确认'
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'REJECTED', version: 2 }))
await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review', payload: expect.objectContaining({ decision: 'REJECT', remark: '取送时间需重新确认', version: 1 }) }))
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
})
it('preserves one immutable request and retries it after an ambiguous failure, then refreshes the detail', async () => {
vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, undefined, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND', version: 2 }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核')
vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, undefined, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'PENDING_DISPATCH', version: 2 }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车')
formModel(wrapper).remark = '核对完成'; await confirm(wrapper)
expect(wrapper.text()).toContain('订单版本冲突')
expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(true)
const original = JSON.parse(JSON.stringify(vi.mocked(executeUsTruckCommand).mock.calls[0][2]))
wrapper.findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
await click(wrapper, '审核'); await confirm(wrapper)
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
await click(wrapper, '审核并生成排车'); await confirm(wrapper)
expect(vi.mocked(executeUsTruckCommand).mock.calls[1][2]).toEqual(original)
expect(wrapper.text()).toContain('登记实收')
expect(wrapper.text()).toContain('排车审核')
})
it('uses delivery as the last flow node and omits point aliases', async () => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'COMPLETED' }))
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.findAll('.truck-flow-label').map(node => node.text())).toEqual(['待审核', '排车审核', '取货', '派送中', '送达'])
expect(wrapper.findAll('.truck-flow li.done')).toHaveLength(5)
expect(wrapper.text()).not.toMatch(/B点|C点|B\/C/)
})
it('locks duplicate submission until the original response arrives', async () => {
let finish!: (value: LocalTransportDetail) => void
vi.mocked(executeUsTruckCommand).mockImplementation(() => new Promise(resolve => { finish = resolve }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核'); await confirm(wrapper); await confirm(wrapper)
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车'); await confirm(wrapper); await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true)
expect(wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).props('confirmLoading')).toBe(true)
for (const button of wrapper.find('.detail-page-layout__header').findAll('button')) expect(button.attributes('disabled')).toBeDefined()
finish(order({ status: 'WAITING_INBOUND', version: 2 })); await flushPromises()
expect(wrapper.text()).toContain('登记实收')
finish(order({ status: 'PENDING_DISPATCH', version: 2 })); await flushPromises()
expect(wrapper.text()).toContain('排车审核')
})
it('validates cumulative receipt totals without submitting invalid input', async () => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'RECEIVING', palletCount: 3, receivedPalletCount: 2 }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '登记实收')
formModel(wrapper).actualPalletCount = 1; await confirm(wrapper)
expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('不能少于已实收托数')
Object.assign(formModel(wrapper), { actualPalletCount: 3, packageCount: -1 }); await confirm(wrapper)
expect(executeUsTruckCommand).not.toHaveBeenCalled()
})
it('hides obsolete pallet actions after approval and keeps the standalone order read-only', async () => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'PENDING_DISPATCH', version: 2 }))
const wrapper = mountDetail(); await flushPromises()
it('requires received pallets and permits partial allocation using their string IDs', async () => {
const pallet = { id: '9007199254741007', sequenceNo: 1, palletNo: 'P-1', source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: null }
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', palletCount: 2, receivedPalletCount: 2, pallets: [pallet] }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '分配托盘'); await confirm(wrapper)
expect(executeUsTruckCommand).not.toHaveBeenCalled()
formModel(wrapper).palletIds = [pallet.id]
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'PARTIALLY_ALLOCATED', version: 2, allocatedPalletCount: 1, pallets: [{ ...pallet, status: 'ALLOCATED' }] }))
await confirm(wrapper)
expect(wrapper.text()).toContain('部分分配'); expect(wrapper.text()).not.toContain('确认待派车')
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'allocate-pallets', payload: expect.objectContaining({ palletIds: [pallet.id] }) }))
})
it.each(['APPROVE', 'REJECT'] as const)('reviews pallet overage with %s', async decision => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'PALLET_OVERAGE_REVIEW', palletCount: 2, receivedPalletCount: 3 }))
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', version: 2 }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '超托审核')
Object.assign(formModel(wrapper), { decision, remark: '已核对超收货物' }); await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review-pallet-overage', payload: expect.objectContaining({ decision }) }))
expect(wrapper.text()).not.toContain('登记实收'); expect(wrapper.text()).not.toContain('分配托盘'); expect(wrapper.text()).not.toContain('超托审核')
})
it('requires cancellation reason and submits only the cancellation status', async () => {
@@ -185,7 +162,7 @@ describe('truck review and fulfillment', () => {
it('keeps list-only users read-only and guards direct attempts to emit submit', async () => {
const wrapper = mountDetail(false); await flushPromises()
expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0)
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
expect(executeUsTruckCommand).not.toHaveBeenCalled()
})
@@ -3,7 +3,7 @@ import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import { ArrowLeft, Check, MapPin } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
@@ -16,7 +16,7 @@ import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import TruckOrderActions from '../components/TruckOrderActions.vue'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { truckStatusLabels } from '../truckWorkflow'
import type { LocalTransportDetail, LocalTransportPallet, OverseasBusinessHistory } from '../types'
import type { LocalTransportDetail, OverseasBusinessHistory } from '../types'
const route = useRoute(), router = useRouter()
const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
@@ -24,33 +24,43 @@ const customerId = computed(() => typeof route.query.customerId === 'string' ? r
const detail = ref<LocalTransportDetail | null>(null), loading = ref(false), saving = ref(false)
const error = ref<ApiErrorDetails | null>(null)
let requestSequence = 0
const flowSteps = ['待审核', '排车审核', '取货', '派送中', '送达']
const dateTime = (value: string | null | undefined) => value ? value.replace('T', ' ').slice(0, 16) : '—'
const statusLabel = (value: string | null) => value ? truckStatusLabels[value as keyof typeof truckStatusLabels] || value : '—'
const flowIndex = computed(() => {
const status = detail.value?.status
if (status === 'PENDING_REVIEW') return 0
if (status === 'IN_TRANSIT') return 3
if (status === 'COMPLETED') return 4
if (status === 'CANCELLED' || status === 'REJECTED') return 0
return 1
})
const fields = computed(() => detail.value ? [
['客户编号', detail.value.customerId], ['车辆类型', detail.value.vehicleType ? ({ PALLET_15: '15拖', PALLET_30: '30拖' })[detail.value.vehicleType] : '—'],
['起运地', detail.value.origin], ['起运地详细地址', detail.value.originAddress], ['起运地联系人', detail.value.originContactName], ['起运地联系电话', detail.value.originContactPhone],
['目的地', detail.value.destination], ['目的地详细地址', detail.value.destinationAddress], ['目的地联系人', detail.value.destinationContactName], ['目的地联系电话', detail.value.destinationContactPhone],
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
['申报件数', detail.value.packageCount], ['总重量(kg)', detail.value.totalWeightKg], ['总体积(m³)', detail.value.totalVolumeCbm],
['车辆类型', detail.value.vehicleType ? ({ PALLET_15: '15拖', PALLET_30: '30拖' })[detail.value.vehicleType] : '—'],
['取货地', detail.value.origin], ['取货地址', detail.value.originAddress],
['取货联系人', detail.value.originContactName], ['取货联系电话', detail.value.originContactPhone],
['送达地', detail.value.destination], ['送达地址', detail.value.destinationAddress],
['送达联系人', detail.value.destinationContactName], ['送达联系电话', detail.value.destinationContactPhone],
['件数', detail.value.packageCount], ['重量(kg)', detail.value.totalWeightKg], ['体积(m³)', detail.value.totalVolumeCbm],
['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`],
['已确认托数', detail.value.palletCount], ['累计实收托数', detail.value.receivedPalletCount], ['已分配托数', detail.value.allocatedPalletCount],
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark],
] : [])
const palletLabels: Record<string, string> = { EXPECTED: '待实收', PLANNED: '待实收', RECEIVED: '已实收', ALLOCATED: '已分配', OVERAGE_PENDING_REVIEW: '超托待审', OVERAGE_REJECTED: '超托拒收' }
const palletColumns: DataTableColumn[] = [
{ key: 'palletNo', prop: 'palletNo', label: '托盘编号', minWidth: 180 },
{ key: 'status', label: '状态', width: 130, formatter: (row: LocalTransportPallet) => palletLabels[row.status] || row.status },
{ key: 'packageCount', prop: 'packageCount', label: '实收件数', width: 120 },
{ key: 'receivedAt', label: '实收时间', minWidth: 170, formatter: (row: LocalTransportPallet) => dateTime(row.receivedAt) },
]
const operations: Record<string, string> = { CREATE: '创建', UPDATE: '编辑', REVIEW: '订单审核', RECEIVE_PALLETS: '登记实收', REVIEW_PALLET_OVERAGE: '超托审核', ALLOCATE_PALLETS: '分配托盘', PREPARE_DISPATCH: '确认待派车', DISPATCH: '确认已派车', START_TRANSIT: '确认发运', COMPLETE: '确认完成', CANCEL: '取消订单' }
const operations: Record<string, string> = {
CREATE: '创建', UPDATE: '编辑', REVIEW: '审核', TRANSPORT_ASSIGN: '接入排车',
RECEIVE_PALLETS: '取货', START_TRANSIT: '派送中', COMPLETE: '送达', LOCAL_TRUCK_DEPART: '取货完成', LOCAL_TRUCK_COMPLETE: '送达', CANCEL: '取消',
DISPATCH: '接入排车', PREPARE_DISPATCH: '接入排车',
}
const historyColumns: DataTableColumn[] = [
{ key: 'createdAt', label: '操作时间', minWidth: 170, formatter: (row: OverseasBusinessHistory) => dateTime(row.createdAt) },
{ key: 'operationType', label: '操作', minWidth: 130, formatter: (row: OverseasBusinessHistory) => operations[row.operationType || ''] || row.operationType || '—' },
{ key: 'fromStatus', label: '原状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.fromStatus) },
{ key: 'toStatus', label: '新状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.toStatus) },
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 140 }, { key: 'remark', prop: 'remark', label: '审核意见/操作说明', minWidth: 240 },
{ key: 'createdAt', label: '时间', minWidth: 170, formatter: (row: OverseasBusinessHistory) => dateTime(row.createdAt) },
{ key: 'operationType', label: '操作', minWidth: 120, formatter: (row: OverseasBusinessHistory) => operations[row.operationType || ''] || row.operationType || '—' },
{ key: 'fromStatus', label: '原状态', width: 120, formatter: (row: OverseasBusinessHistory) => statusLabel(row.fromStatus) },
{ key: 'toStatus', label: '新状态', width: 120, formatter: (row: OverseasBusinessHistory) => statusLabel(row.toStatus) },
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 120 },
{ key: 'remark', prop: 'remark', label: '说明', minWidth: 220 },
]
async function load() {
if (saving.value) return
const sequence = ++requestSequence
@@ -84,11 +94,21 @@ watch([orderNo, customerId], load, { immediate: true })
<LoadErrorState v-if="error" title="本土卡车订单暂时无法加载" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-skeleton v-else-if="loading" :rows="8" animated />
<template v-else-if="detail">
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /><p class="truck-detail-note">取货和送达照片请在排车任务中上传。</p></section>
<section class="truck-detail-section truck-flow-section" aria-label="运输流程">
<div class="truck-section-heading"><h2>运输流程</h2><span class="truck-flow-caption">审核通过后由排车单推进取货、派送和送达</span></div>
<ol class="truck-flow">
<li v-for="(step, index) in flowSteps" :key="step" :class="{ active: index === flowIndex, done: index < flowIndex || detail.status === 'COMPLETED' }">
<span class="truck-flow-marker"><Check v-if="index < flowIndex || detail.status === 'COMPLETED'" :size="14" /><MapPin v-else-if="index === 2 || index === 4" :size="14" /><span v-else>{{ index + 1 }}</span></span>
<span class="truck-flow-label">{{ step }}</span>
</li>
</ol>
</section>
<section class="truck-detail-section"><div class="truck-section-heading"><h2>订单资料</h2><span class="truck-flow-caption">基础信息与货量</span></div><dl class="truck-detail-grid"><div v-for="[label, value] in fields" :key="String(label)" class="truck-detail-fact"><dt>{{ label }}</dt><dd>{{ value ?? '—' }}</dd></div></dl></section>
<section class="truck-detail-section"><BusinessDocumentFees business-type="US_TRUCK" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
<section class="truck-detail-section"><h2>托盘记录</h2><div class="workbench-table"><DataTable :data="detail.pallets" :columns="palletColumns" empty-text="暂无托盘记录,独立订单首次实收后生成托盘" /></div></section>
<section class="truck-detail-section"><h2>审核与操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
<section class="truck-detail-section"><div class="truck-section-heading"><h2>审核与操作记录</h2><span class="truck-flow-caption">保留关键节点</span></div><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
</template>
</DetailPageLayout></AdminPage>
</template>
@@ -99,4 +119,25 @@ watch([orderNo, customerId], load, { immediate: true })
.truck-detail-header .status-badge { flex-shrink: 0; }
.truck-detail-actions { flex-wrap: wrap; }
.truck-detail-section { margin-bottom: var(--admin-space-6); }
.truck-section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.truck-section-heading h2 { margin: 0; font-size: 16px; color: var(--cc-text); }
.truck-flow-caption, .truck-detail-note { color: var(--cc-muted); font-size: 12px; }
.truck-detail-note { margin: 10px 0 0; }
.truck-flow { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; margin: 0; padding: 0; list-style: none; }
.truck-flow li { position: relative; display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 0; color: var(--cc-muted); text-align: center; }
.truck-flow li::before { content: ''; position: absolute; top: 15px; left: -50%; width: 100%; height: 1px; background: var(--cc-border); }
.truck-flow li:first-child::before { display: none; }
.truck-flow li.done::before, .truck-flow li.active::before { background: var(--el-color-primary); }
.truck-flow-marker { position: relative; z-index: 1; display: grid; place-items: center; width: 30px; height: 30px; border: 1px solid var(--cc-border); border-radius: 50%; background: var(--cc-surface); color: var(--cc-muted); font-size: 12px; }
.truck-flow li.done .truck-flow-marker, .truck-flow li.active .truck-flow-marker { border-color: var(--el-color-primary); color: var(--el-color-primary); }
.truck-flow li.active .truck-flow-marker { background: var(--el-color-primary); color: #fff; box-shadow: 0 0 0 4px color-mix(in srgb, var(--el-color-primary) 14%, transparent); }
.truck-flow-label { font-size: 12px; white-space: nowrap; }
.truck-flow li.active .truck-flow-label { color: var(--el-color-primary); font-weight: 600; }
.truck-detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-lg); overflow: hidden; background: var(--cc-border); }
.truck-detail-fact { min-width: 0; display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 10px; padding: 11px 14px; background: var(--cc-surface); }
.truck-detail-fact dt { color: var(--cc-muted); font-size: 12px; }
.truck-detail-fact dd { min-width: 0; margin: 0; color: var(--cc-text); overflow-wrap: anywhere; font-size: 13px; }
.truck-detail-fact:last-child { grid-column: span 2; }
@media (max-width: 1100px) { .truck-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 760px) { .truck-flow { overflow-x: auto; grid-template-columns: repeat(5, 100px); padding-bottom: 4px; } .truck-detail-grid { grid-template-columns: 1fr; } .truck-detail-fact:last-child { grid-column: auto; } }
</style>
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { requireReviewedProcurementFees } from './procurementOutboundFees'
describe('procurement outbound fee gate', () => {
const approved = { direction: 'RECEIVABLE' as const, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' }
it('requires a current receivable, excluding voided fees and payables', () => {
for (const rows of [[], [{ ...approved, sourceStatus: 'VOIDED' }], [{ ...approved, direction: 'PAYABLE' as const }]]) {
expect(() => requireReviewedProcurementFees(rows)).toThrow('请先添加费用明细')
}
})
it('requires every current fee to be active and approved', () => {
for (const reviewStatus of ['PENDING', 'REJECTED']) {
expect(() => requireReviewedProcurementFees([approved, { ...approved, reviewStatus }])).toThrow('尚未全部审核通过')
}
expect(() => requireReviewedProcurementFees([approved, { ...approved, sourceStatus: 'VOIDING' }])).toThrow('尚未全部审核通过')
expect(() => requireReviewedProcurementFees([approved, { ...approved, sourceStatus: 'VOIDED', reviewStatus: 'REJECTED' }])).not.toThrow()
})
})
@@ -0,0 +1,7 @@
import type { OverseasFee } from './api/overseasFees'
export function requireReviewedProcurementFees(rows: Pick<OverseasFee, 'direction' | 'sourceStatus' | 'reviewStatus'>[]) {
const fees = rows.filter(fee => fee.direction === 'RECEIVABLE' && fee.sourceStatus !== 'VOIDED')
if (!fees.length) throw new Error('请先添加费用明细并审核通过后再出库')
if (fees.some(fee => fee.sourceStatus !== 'ACTIVE' || fee.reviewStatus !== 'APPROVED')) throw new Error('费用明细尚未全部审核通过,请先完成费用审核后再出库')
}
@@ -2,21 +2,19 @@ import { regionText } from './procurementDirection'
import type { Amount, ProcurementFee } from './procurementTypes'
export const procurementStatuses: Record<string, string> = {
PENDING_REVIEW: '待审核', WAIT_PURCHASE: '待购买', WAIT_OVERSEAS_RECEIPT: '待海外到仓', OVERSEAS_RECEIVING: '海外收货中',
WAIT_PACKING: '待装箱', WAIT_LABELS: '待面单及预估处理', WAIT_OVERSEAS_SHIPMENT: '待海外交寄', INTERNATIONAL_TRANSIT: '国际运输中',
DOMESTIC_RECEIVING: '国内收货中', WAIT_CHARGES: '待国内核费', WAIT_CUSTOMER_SHIPMENT: '待寄客户', SHIPPED_TO_CUSTOMER: '已寄客户',
REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭',
PENDING_REVIEW: '待审核', RECEIVING: '收货中', WAIT_CUSTOMER_CONFIRM: '待客户确认', CONFIRMED: '已确认',
IN_TRANSIT: '运输中', DELIVERING: '派送中', COMPLETED: '签收完成',
}
export const feePurposes: Record<string, string> = {
SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费',
DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款',
}
export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || '待核对进度' : '—', direction) }
export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || ({ REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭' } as Record<string, string>)[status] || '待核对进度' : '—', direction) }
export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
if (status === 'SHIPPED_TO_CUSTOMER') return 'success'
if (status === 'COMPLETED') return 'success'
if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger'
if (status === 'CANCELLED') return 'info'
if (status === 'INTERNATIONAL_TRANSIT') return 'primary'
if (status === 'IN_TRANSIT' || status === 'DELIVERING') return 'primary'
return 'warning'
}
export function cents(value: Amount | null | undefined): bigint | null {
+1 -1
View File
@@ -6,7 +6,7 @@ export const overseasBusinessRoutes = [
path: 'overseas-business/fees',
name: 'overseas-business-fees',
component: () => import('./pages/OverseasFeesView.vue'),
meta: { title: '海外费用管理', permission: 'platform:overseas-fee:list' },
meta: { title: '海外费用明细', permission: 'platform:overseas-fee:list' },
},
{
path: 'overseas-business/purchases',
@@ -32,10 +32,14 @@ export function createTransportDemandForm(): TransportDemandForm {
}
}
const requiredText = {
origin: '起运地', destination: '目的地', originContactName: '起运地联系人',
originContactPhone: '起运地联系电话', destinationContactName: '目的地联系人', destinationContactPhone: '目的地联系电话',
} as const
const requiredText = (kind: TransportDemandKind) => ({
origin: kind === 'truck' ? 'B点取货地' : '起运地',
destination: kind === 'truck' ? 'C点送达地' : '目的地',
originContactName: kind === 'truck' ? 'B点联系人' : '起运地联系人',
originContactPhone: kind === 'truck' ? 'B点联系电话' : '起运地联系电话',
destinationContactName: kind === 'truck' ? 'C点联系人' : '目的地联系人',
destinationContactPhone: kind === 'truck' ? 'C点联系电话' : '目的地联系电话',
} as const)
export function localMinuteInput(value: unknown) {
return typeof value === 'string' && value ? `${value.slice(0, 16)}:00` : ''
@@ -43,8 +47,9 @@ export function localMinuteInput(value: unknown) {
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
const rules: FormRules<TransportDemandForm> = {}
for (const [field, label] of Object.entries(requiredText)) {
rules[field as keyof typeof requiredText] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
const labels = requiredText(kind)
for (const [field, label] of Object.entries(labels)) {
rules[field as keyof typeof labels] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
}
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
@@ -52,23 +57,26 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
}
rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }]
rules.pickupAt = [{ required: true, message: '请选择取货时间', trigger: 'change' }]
rules.deliveryAt = [{ required: true, message: '请选择送货时间', trigger: 'change' }, {
validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change',
const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间'
rules.pickupAt = [{ required: true, message: `请选择${pickupLabel}`, trigger: 'change' }]
rules.deliveryAt = [{ required: true, message: `请选择${deliveryLabel}`, trigger: 'change' }, {
validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error(`${deliveryLabel}不能早于${pickupLabel}`) : undefined), trigger: 'change',
}]
return rules
}
export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined {
if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型'
for (const [field, label] of Object.entries(requiredText)) {
if (!String(form[field as keyof typeof requiredText] || '').trim()) return `请输入${label}`
const labels = requiredText(kind)
for (const [field, label] of Object.entries(labels)) {
if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}`
}
for (const [field, label] of [['pickupAt', '取货时间'], ['deliveryAt', '送货时间']] as const) {
const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间'
for (const [field, label] of [['pickupAt', pickupLabel], ['deliveryAt', deliveryLabel]] as const) {
if (!form[field]) return `请选择${label}`
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}`
}
if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return '送货时间不能早于取货时间'
if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return `${deliveryLabel}不能早于${pickupLabel}`
for (const [field, label] of [['packageCount', '件数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积'], ...(kind === 'looseboard' ? [['estimatedPalletCount', '预计托数'] as const] : [])] as const) {
if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字`
}
+13 -18
View File
@@ -1,40 +1,35 @@
import type { LocalTransportDetail, LocalTransportStatus } from './types'
export const truckStatusLabels: Record<LocalTransportStatus, string> = {
PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托',
PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配',
PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消',
PENDING_REVIEW: '待审核', WAITING_INBOUND: '待取货', RECEIVING: '待取货', WAITING_CONSOLIDATION: '待取货',
PALLET_OVERAGE_REVIEW: '待取货', PARTIALLY_ALLOCATED: '待取货', ALLOCATED: '待取货',
PENDING_DISPATCH: '待取货', DISPATCHED: '待取货', IN_TRANSIT: '派送中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消',
}
export const truckCommandLabels = {
review: '审核', 'receive-pallets': '登记实收', 'review-pallet-overage': '超托审核', 'allocate-pallets': '分配托盘',
review: '审核并生成排车', arrange: '补建排车单', 'receive-pallets': '登记实收', 'review-pallet-overage': '超托审核', 'allocate-pallets': '分配托盘',
'prepare-dispatch': '确认待派车', dispatch: '确认已派车', 'start-transit': '确认发运', complete: '确认完成', cancel: '取消订单',
} as const
export type TruckCommand = keyof typeof truckCommandLabels
export interface TruckCommandBase { version: number; idempotencyKey: string; remark?: string }
export type TruckOperation =
| { command: 'review' | 'review-pallet-overage'; payload: TruckCommandBase & { decision: 'APPROVE' | 'REJECT' } }
| { command: 'review'; payload: TruckCommandBase & { decision: 'APPROVE' | 'REJECT'; warehouseId?: string } }
| { command: 'arrange'; payload: TruckCommandBase & { warehouseId: string } }
| { command: 'review-pallet-overage'; payload: TruckCommandBase & { decision: 'APPROVE' | 'REJECT' } }
| { command: 'receive-pallets'; payload: TruckCommandBase & { actualPalletCount: number; packageCount?: number; palletPackageCounts?: number[] } }
| { command: 'allocate-pallets'; payload: TruckCommandBase & { palletIds: string[] } }
| { command: 'prepare-dispatch' | 'dispatch' | 'start-transit' | 'complete'; payload: TruckCommandBase }
| { command: 'cancel'; payload: TruckCommandBase & { status: 'CANCELLED' } }
const nextCommands: Partial<Record<LocalTransportStatus, TruckCommand>> = {
PENDING_REVIEW: 'review', WAITING_INBOUND: 'receive-pallets', RECEIVING: 'receive-pallets',
PALLET_OVERAGE_REVIEW: 'review-pallet-overage', WAITING_CONSOLIDATION: 'allocate-pallets',
PARTIALLY_ALLOCATED: 'allocate-pallets', ALLOCATED: 'prepare-dispatch', PENDING_DISPATCH: 'dispatch',
DISPATCHED: 'start-transit', IN_TRANSIT: 'complete',
}
const cancellable = new Set<LocalTransportStatus>(['PENDING_REVIEW', 'WAITING_INBOUND', 'RECEIVING', 'PALLET_OVERAGE_REVIEW', 'WAITING_CONSOLIDATION', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH'])
/** 拼车来源只读,独立单据只能执行当前状态对应的具名命令。 */
/** 拼车来源只读;原单只保留审核入口,排车后的执行动作统一在排车任务完成。 */
export function availableTruckCommands(order: Pick<LocalTransportDetail, 'status' | 'fleetArrangement'>): TruckCommand[] {
if (order.fleetArrangement) return []
const next = nextCommands[order.status]
return [...(next ? [next] : []), ...(cancellable.has(order.status) ? ['cancel' as const] : [])]
if (order.status === 'PENDING_REVIEW') return ['review', 'cancel']
return order.status === 'PENDING_DISPATCH' ? ['arrange', 'cancel'] : []
}
export const truckCommandHints: Record<TruckCommand, string> = {
review: '请核对车型、路线、两端联系信息、取送时间和货物资料。通过后进入待入仓,拒绝后结束本次申请。',
review: '核对基础资料后进入排车审核。审核通过后,取货照片、派送中和送达照片均在排车单中完成。',
arrange: '选择真实 A 海外仓,补建本土卡车排车单。',
'receive-pallets': '按实际收货填写截至本次的累计实收托数。首次实收确定本单托数,后续超出已确认托数需要超托审核。',
'review-pallet-overage': '通过后将超收托盘纳入本单;拒绝后将超收托盘标记为拒收,按原已确认托数继续。',
'allocate-pallets': '选择本单已实收且未分配的托盘,可分批分配;全部已接收托盘分配完成后才能进入待派车。',
@@ -42,5 +37,5 @@ export const truckCommandHints: Record<TruckCommand, string> = {
dispatch: '请在车辆安排已实际完成后确认,本单将进入已派车。',
'start-transit': '请在车辆已实际发运后确认,本单将进入运输中。',
complete: '请在运输及交付已实际完成后确认。确认完成后不能撤回。',
cancel: '取消后本单不能继续履约,请填写取消原因并核对已有收货和托盘记录。',
cancel: '取消后本单不能继续履约,请填写取消原因。',
}
+1 -1
View File
@@ -5,7 +5,7 @@ export interface OverseasBusinessHistory {
operatorId: string | null; changeDetail?: string | null; remark: string | null; createdAt: string
}
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'WAIT_PURCHASE' | 'WAIT_OVERSEAS_RECEIPT' | 'OVERSEAS_RECEIVING' | 'WAIT_PACKING' | 'WAIT_LABELS' | 'WAIT_OVERSEAS_SHIPMENT' | 'INTERNATIONAL_TRANSIT' | 'DOMESTIC_RECEIVING' | 'WAIT_CHARGES' | 'WAIT_CUSTOMER_SHIPMENT' | 'SHIPPED_TO_CUSTOMER' | 'REJECTED' | 'CANCELLED' | 'EXCEPTION_CLOSED'
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'RECEIVING' | 'WAIT_CUSTOMER_CONFIRM' | 'CONFIRMED' | 'IN_TRANSIT' | 'DELIVERING' | 'COMPLETED'
export interface OverseasPurchaseQuery {
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | ''
@@ -76,3 +76,11 @@ describe('owned business HTTP contract', () => {
await expect(uploadOwnedBusinessPhoto('DS-OWN-1','FUEL_START',new File(['text'],'bad.txt',{type:'text/plain'}),'key')).rejects.toThrow('JPG')
})
})
it('accepts unknown transport ownership without guessing current master data', async () => {
const detail = ownedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, taskType: 'LOCAL_TRUCK', actualPalletCount: null, vehicle: { ...detail.row.vehicle, ownership: null } } } })
const result = await getOwnedBusiness(detail.row.dispatchNo)
expect(result.row.vehicle.ownership).toBeNull()
expect(result.row.actualPalletCount).toBeNull()
})
@@ -16,7 +16,7 @@ function operations(value: OwnedBusinessOperations): OwnedBusinessOperations {
return { ...value, money: value.money ? { ...value.money, amount: decimal(value.money.amount, 2, 18)! } : null, actualDistanceMiles: decimal(value.actualDistanceMiles,3,12), fuelConsumptionLitresPer100Km: decimal(value.fuelConsumptionLitresPer100Km,3,12) }
}
function row(value: OwnedBusinessRow): OwnedBusinessRow {
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || !['OWNED','DISPATCHED'].includes(value.vehicle?.ownership)) throw new Error('自有排车来源或阶段不完整,请重新读取')
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || !(['OWNED','DISPATCHED'].includes(value.vehicle?.ownership || '') || (value.vehicle?.ownership === null && ['LOCAL_TRUCK','PICKUP'].includes(value.taskType || '')))) throw new Error('自有排车来源或阶段不完整,请重新读取')
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: ownedBusinessVersion(value.recordVersion), dispatchRecordVersion: ownedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount), fuelCount: count(value.photoSummary.fuelCount) } }
}
function detail(value: OwnedBusinessDetail): OwnedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
@@ -46,3 +46,11 @@ it('rejects JSON download errors and preserves correlation evidence instead of p
vi.mocked(http.get).mockResolvedValue({ code: 'A0403', message: '无权读取', requestId: 'request-evidence', traceId: 'trace-evidence' })
await expect(downloadTripOperationsEvidence('TR1', '81')).rejects.toMatchObject({ code: 'A0403', requestId: 'request-evidence', traceId: 'trace-evidence' })
})
it('keeps transport pallet facts unknown while retaining actual shipment quantities', async () => {
vi.clearAllMocks()
const counts = { physicalPallets: null, palletSpaces: null, ownPackages: '10', externalPackages: '0', externalAgreedAmounts: [] }
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { page: { records: [{ ...counts, tripNo: 'LOCAL-1', destinations: [], externalPalletRates: [], measured: null, costs: null }], total: '1', page: '1', size: '20', pages: '1' }, summary: { ...counts, physicalPallets: '0', palletSpaces: '0', trips: '1', inTransit: '1', completed: '0', costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } } })
const result = await listTripOperations({ from: '2026-10-26T00:00:00Z', to: '2026-10-28T00:00:00Z' })
expect(result.page.records[0]).toMatchObject({ physicalPallets: null, palletSpaces: null, ownPackages: 10, externalPackages: 0 })
})
@@ -15,12 +15,12 @@ export interface TripOperationsSeries { label: string; unit: string; points: { l
export interface TripOperationsCharts { physicalPallets: TripOperationsSeries[]; destinationTrips: TripOperationsSeries[]; externalAgreedAmounts: TripOperationsSeries[]; externalPalletRates: TripOperationsSeries[] }
export interface TripOperationsRow {
tripNo: string; status: string; vehiclePlate: string | null; driverName: string | null; warehouseName: string | null; departedAt: string; completedAt: string | null; destinations: { key: string; label: string }[]
physicalPallets: number; palletSpaces: number; ownPackages: number; externalPackages: number; externalAgreedAmounts: TripOperationsMoney[]; financeStatus: string; costStatus: string
physicalPallets: number | null; palletSpaces: number | null; ownPackages: number; externalPackages: number; externalAgreedAmounts: TripOperationsMoney[]; financeStatus: string; costStatus: string
externalPalletRates: { currency: string; pricedPalletSpaces: number; agreedAmount: number }[]; measured: TripOperationsMeasured | null; costs: OperationsRowCosts | null
}
export interface TripOperationsSummary { trips: number; inTransit: number; completed: number; physicalPallets: number; palletSpaces: number; ownPackages: number; externalPackages: number; externalAgreedAmounts: TripOperationsMoney[]; costs: OperationsCosts }
export interface TripOperationsResult { page: PageResult<TripOperationsRow>; summary: TripOperationsSummary; charts: TripOperationsCharts }
type WireNumbers<T> = { [K in keyof T]: T[K] extends number ? number | string : T[K] }
type WireNumbers<T> = { [K in keyof T]: T[K] extends number | null ? number | string | Extract<T[K], null> : T[K] }
type WireMoney = { currency: string; amount: string | number }
type WireEvidence = Omit<TripOperationsEvidence, 'sizeBytes'> & { sizeBytes: number | string }
type WireMeasured = { actualDistanceMiles: string | number | null; actualFuelLitres: string | number | null; remark: string | null; evidenceFiles?: WireEvidence[] }
@@ -29,7 +29,7 @@ type WireRow = Omit<WireNumbers<TripOperationsRow>, 'externalAgreedAmounts' | 'm
type WireSummary = Omit<WireNumbers<TripOperationsSummary>, 'externalAgreedAmounts' | 'costs'> & { externalAgreedAmounts: WireMoney[]; costs: { visible: boolean; unrecordedTrips: string | number | null; unallocatedTrips: string | number | null; totals: WireCostTotal[] } }
const costTotals = (rows: WireCostTotal[]): OperationsCostTotal[] => rows.map(r => ({ ...r, directCost: fleetMoney(r.directCost), ownCost: r.ownCost == null ? null : fleetMoney(r.ownCost), externalCost: r.externalCost == null ? null : fleetMoney(r.externalCost) }))
const money = (rows: WireMoney[]) => rows.map(r => ({ ...r, amount: fleetMoney(r.amount) }))
const counts = (r: WireRow | WireSummary) => ({ physicalPallets: fleetNumber(r.physicalPallets), palletSpaces: fleetNumber(r.palletSpaces), ownPackages: fleetNumber(r.ownPackages), externalPackages: fleetNumber(r.externalPackages), externalAgreedAmounts: money(r.externalAgreedAmounts) })
const counts = (r: WireRow | WireSummary) => ({ physicalPallets: r.physicalPallets == null ? null : fleetNumber(r.physicalPallets), palletSpaces: r.palletSpaces == null ? null : fleetNumber(r.palletSpaces), ownPackages: fleetNumber(r.ownPackages), externalPackages: fleetNumber(r.externalPackages), externalAgreedAmounts: money(r.externalAgreedAmounts) })
const evidence = (r: WireEvidence): TripOperationsEvidence => {
if (typeof r.fileId !== 'string' || !/^[1-9]\d*$/.test(r.fileId) || !r.fileName || !['application/pdf', 'image/jpeg', 'image/png'].includes(r.contentType)) throw new Error('实测凭证信息无效')
return { ...r, sizeBytes: fleetNumber(r.sizeBytes) }
@@ -40,7 +40,7 @@ type WireCharts = { [K in keyof TripOperationsCharts]: WireSeries[] }
const series = (values: WireSeries[]) => values.map(s => ({ ...s, points: s.points.map(p => ({ ...p, value: fleetNumber(p.value) })) }))
export async function listTripOperations(query: TripOperationsQuery): Promise<TripOperationsResult> {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ page: PageResult<WireRow>; summary: WireSummary; charts: WireCharts }>, ApiEnvelope<{ page: PageResult<WireRow>; summary: WireSummary; charts: WireCharts }>>('/platform/v1/last-leg/fleet/operations', { params: query }))
return { page: fleetPage({ ...result.page, records: result.page.records.map(r => ({ ...r, ...counts(r), measured: r.measured == null ? null : measured(r.measured), externalPalletRates: r.externalPalletRates.map(p => ({ ...p, pricedPalletSpaces: fleetNumber(p.pricedPalletSpaces), agreedAmount: fleetMoney(p.agreedAmount) })), costs: r.costs == null ? null : { allocationBlockReason: r.costs.allocationBlockReason, totals: costTotals(r.costs.totals) } })) }), summary: { ...result.summary, ...counts(result.summary), trips: fleetNumber(result.summary.trips), inTransit: fleetNumber(result.summary.inTransit), completed: fleetNumber(result.summary.completed), costs: { ...result.summary.costs, unrecordedTrips: result.summary.costs.unrecordedTrips == null ? null : fleetNumber(result.summary.costs.unrecordedTrips), unallocatedTrips: result.summary.costs.unallocatedTrips == null ? null : fleetNumber(result.summary.costs.unallocatedTrips), totals: costTotals(result.summary.costs.totals) } }, charts: { physicalPallets: series(result.charts.physicalPallets), destinationTrips: series(result.charts.destinationTrips), externalAgreedAmounts: series(result.charts.externalAgreedAmounts), externalPalletRates: series(result.charts.externalPalletRates) } }
return { page: fleetPage({ ...result.page, records: result.page.records.map(r => ({ ...r, ...counts(r), measured: r.measured == null ? null : measured(r.measured), externalPalletRates: r.externalPalletRates.map(p => ({ ...p, pricedPalletSpaces: fleetNumber(p.pricedPalletSpaces), agreedAmount: fleetMoney(p.agreedAmount) })), costs: r.costs == null ? null : { allocationBlockReason: r.costs.allocationBlockReason, totals: costTotals(r.costs.totals) } })) }), summary: { ...result.summary, ...counts(result.summary), physicalPallets: fleetNumber(result.summary.physicalPallets), palletSpaces: fleetNumber(result.summary.palletSpaces), trips: fleetNumber(result.summary.trips), inTransit: fleetNumber(result.summary.inTransit), completed: fleetNumber(result.summary.completed), costs: { ...result.summary.costs, unrecordedTrips: result.summary.costs.unrecordedTrips == null ? null : fleetNumber(result.summary.costs.unrecordedTrips), unallocatedTrips: result.summary.costs.unallocatedTrips == null ? null : fleetNumber(result.summary.costs.unallocatedTrips), totals: costTotals(result.summary.costs.totals) } }, charts: { physicalPallets: series(result.charts.physicalPallets), destinationTrips: series(result.charts.destinationTrips), externalAgreedAmounts: series(result.charts.externalAgreedAmounts), externalPalletRates: series(result.charts.externalPalletRates) } }
}
export interface TripOperationsRecord { tripNo: string; recordVersion: string; details: TripOperationsMeasured; updatedAt: string | null }
export interface TripOperationsRecordSave { recordVersion: string; idempotencyKey: string; details: TripOperationsMeasuredInput; reason: string | null }
@@ -10,7 +10,8 @@ export interface OwnedBusinessDestination { destinationKey: string; warehouseCod
export interface OwnedBusinessRow {
dispatchNo: string; stage: OwnedBusinessStage; plannedDepartureAt: string; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
origin: { warehouseId: string; warehouseCode: string; warehouseName: string }
vehicle: { vehicleId: string | null; plate: string | null; ownership: 'OWNED' | 'DISPATCHED' }; driver: { name: string | null; phone: string | null }
taskType?: 'DELIVERY' | 'LOCAL_TRUCK' | 'PICKUP'
vehicle: { vehicleId: string | null; plate: string | null; ownership: 'OWNED' | 'DISPATCHED' | null }; driver: { name: string | null; phone: string | null }
destinations: OwnedBusinessDestination[]; actualPalletCount: number | null; operations: OwnedBusinessOperations; unitPrice: string | null; recordVersion: string; dispatchRecordVersion: string
photoSummary: { loadingCount: number; fuelCount: number }; actions: { edit: OwnedBusinessGate; upload: OwnedBusinessGate }; sourceIssues: string[]
}
@@ -27,7 +27,7 @@ const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户'
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
const taskFields = computed(() => {
const row = detail.value?.row
return [['计划发车', time(row?.plannedDepartureAt)], ['装车完成', time(row?.loadingCompletedAt)], ['实际发车', time(row?.departedAt)], ['派送完成', time(row?.deliveryCompletedAt)], ['车牌', text(row?.vehicle.plate)], ['司机', text(row?.driver.name)], ['司机电话', text(row?.driver.phone)], ['提货仓库', text(row?.origin.warehouseName)], ['实际物理托数', text(row?.actualPalletCount)], ['整车金额', row?.operations.money ? `${row.operations.money.currency} ${row.operations.money.amount}` : '未登记'], ['单价', row?.unitPrice == null ? '—' : `${row.operations.money?.currency || ''} ${row.unitPrice}`], ['实际里程(mile)', row?.operations.actualDistanceMiles ?? '未登记'], ['油耗(L/100km)', row?.operations.fuelConsumptionLitresPer100Km ?? '未登记']]
return [['计划发车', time(row?.plannedDepartureAt)], ['装车完成', time(row?.loadingCompletedAt)], ['实际发车', time(row?.departedAt)], ['派送完成', time(row?.deliveryCompletedAt)], ['车牌', text(row?.vehicle.plate)], ['司机', text(row?.driver.name)], ['司机电话', text(row?.driver.phone)], [row?.taskType === 'LOCAL_TRUCK' ? '起点' : '提货仓库', text(row?.origin.warehouseName)], ['实际物理托数', text(row?.actualPalletCount)], ['整车金额', row?.operations.money ? `${row.operations.money.currency} ${row.operations.money.amount}` : '未登记'], ['单价', row?.unitPrice == null ? '—' : `${row.operations.money?.currency || ''} ${row.unitPrice}`], ['实际里程(mile)', row?.operations.actualDistanceMiles ?? '未登记'], ['油耗(L/100km)', row?.operations.fuelConsumptionLitresPer100Km ?? '未登记']]
})
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
function destinationLabel(key: string) { const value = detail.value?.row.destinations.find(destination => destination.destinationKey === key); return value?.warehouseCode || value?.warehouseName || value?.address || '—' }
@@ -36,9 +36,9 @@ const summary = computed(() => [
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
{ key: 'destinations', label: '目的仓', minWidth: 150, slotName: 'destinations' },
{ key: 'deliveryWarehouses', label: '派送仓库', minWidth: 150, slotName: 'destinations' },
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '起点', minWidth: 150 },
{ key: 'destinations', label: '目的地', minWidth: 150, slotName: 'destinations' },
{ key: 'fuel', label: '油耗(L/100km)', minWidth: 145, slotName: 'fuel' }, { prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
{ key: 'money', label: '总价', minWidth: 170, align: 'right', slotName: 'money' },
{ key: 'unitPrice', label: '单价(/托)', minWidth: 160, align: 'right', slotName: 'unitPrice' },
@@ -50,6 +50,10 @@ const columns: DataTableColumn[] = [
{ key: 'stage', label: '状态', minWidth: 110, slotName: 'stage' },
]
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
// 全列无值时只显示一个占位;多站有值时保留顺序,目的地通过悬停说明。
function hasAppointments(row: OwnedBusinessRow) { return row.destinations.some(destination => destination.appointments.length > 0) }
function hasScheduledArrival(row: OwnedBusinessRow) { return row.destinations.some(destination => destination.appointments.some(appointment => !!appointment.scheduledAt)) }
function hasActualArrival(row: OwnedBusinessRow) { return row.destinations.some(destination => !!destination.actualArrivedAt) }
async function load() {
if (!active) return
const request = ++generation
@@ -102,10 +106,10 @@ onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
<template #notices><LoadErrorState v-if="error" title="自有业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></template>
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
<template #appointments="{ row }"><template v-if="hasAppointments(row as OwnedBusinessRow)"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination" :title="destination.warehouseCode || destination.warehouseName || destination.address || undefined"><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template><span v-else>—</span></template>
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination">{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</div><span v-if="!row.destinations.length">—</span></template>
<template #scheduledArrival="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ time(appointment.scheduledAt) }}</div><span v-if="!destination.appointments.length">—</span></div><span v-if="!row.destinations.length">—</span></template>
<template #actualArrival="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div>{{ time(destination.actualArrivedAt) }}</div></div><span v-if="!row.destinations.length">—</span></template>
<template #scheduledArrival="{ row }"><template v-if="hasScheduledArrival(row as OwnedBusinessRow)"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination" :title="destination.warehouseCode || destination.warehouseName || destination.address || undefined"><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ time(appointment.scheduledAt) }}</div><span v-if="!destination.appointments.length">—</span></div></template><span v-else>—</span></template>
<template #actualArrival="{ row }"><template v-if="hasActualArrival(row as OwnedBusinessRow)"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination" :title="destination.warehouseCode || destination.warehouseName || destination.address || undefined">{{ time(destination.actualArrivedAt) }}</div></template><span v-else>—</span></template>
<template #fuel="{ row }">{{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }}</template>
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}</template><span v-else>未登记</span></template>
<template #unitPrice="{ row }"><template v-if="row.operations.money && row.unitPrice != null">{{ row.operations.money.currency }} {{ row.unitPrice }}</template><span v-else>{{ row.operations.money ? '—' : '未登记' }}</span></template>
@@ -38,19 +38,35 @@ describe('owned business views', () => {
calls.list.mockResolvedValue({ records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } })
const wrapper = mount(OwnedBusinessView, { global }); await flushPromises()
const columns = wrapper.findComponent(Table).props('columns') as { key?: string; label: string; slotName?: string }[]
expect(columns.map(column => column.label)).toEqual(expect.arrayContaining(['目的仓', '派送仓库', '总价', '单价(/托)', '最晚装车完成时间', '预约到达时间', '实际到达时间', '状态']))
expect(columns.find(column => column.key === 'deliveryWarehouses')!.slotName).toBe('destinations')
expect(columns.map(column => column.label)).toEqual(expect.arrayContaining(['目的地', '总价', '单价(/托)', '最晚装车完成时间', '预约到达时间', '实际到达时间', '状态']))
expect(columns.filter(column => column.slotName === 'destinations')).toHaveLength(1)
expect(wrapper.get('[data-column="money"]').text()).toBe('USD 0.00')
expect(wrapper.get('[data-column="unitPrice"]').text()).toBe('USD 0.00')
expect(wrapper.get('[data-column="latestLoaded"]').text()).toBe('—')
const scheduled = wrapper.findAll('[data-column="scheduledArrival"] .owned-business-destination')
expect(scheduled.map(cell => cell.text())).toEqual(['FBA-1' + dayjs('2026-10-21T09:00:00Z').format('YYYY-MM-DD HH:mm'), 'FBA-2—'])
expect(scheduled.map(cell => cell.text())).toEqual([dayjs('2026-10-21T09:00:00Z').format('YYYY-MM-DD HH:mm'), '—'])
expect(scheduled.map(cell => cell.attributes('title'))).toEqual(['FBA-1', 'FBA-2'])
const actual = wrapper.findAll('[data-column="actualArrival"] .owned-business-destination')
expect(actual.map(cell => cell.text())).toEqual(['FBA-1—', 'FBA-2' + dayjs('2026-10-21T10:00:00Z').format('YYYY-MM-DD HH:mm')])
expect(actual.map(cell => cell.text())).toEqual(['—', dayjs('2026-10-21T10:00:00Z').format('YYYY-MM-DD HH:mm')])
expect(calls.detail).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows one placeholder for missing appointment and arrival values without warehouse names', async () => {
const detail = ownedBusinessFixture()
detail.row.destinations = [
{ destinationKey: 'one', warehouseCode: null, warehouseName: '洛杉矶仓', address: null, appointments: [], actualArrivedAt: null },
{ destinationKey: 'two', warehouseCode: null, warehouseName: '龙华', address: null, appointments: [], actualArrivedAt: null },
]
calls.list.mockResolvedValue({ records: [detail.row], page: 1, size: 20, total: 1, summary: {} })
const wrapper = mount(OwnedBusinessView, { global }); await flushPromises()
for (const column of ['appointments', 'scheduledArrival', 'actualArrival']) {
expect(wrapper.get(`[data-column="${column}"]`).text()).toBe('—')
}
expect(wrapper.get('[data-column="destinations"]').text()).toContain('洛杉矶仓')
wrapper.unmount()
})
it('keeps a non-pool external vehicle returned by the server without filtering or requiring a vehicle id', async () => {
const detail = ownedBusinessFixture()
detail.row.vehicle = { vehicleId: null, plate: 'EXTERNAL-NO-POOL', ownership: 'DISPATCHED' }
@@ -36,10 +36,10 @@ const fields: QueryField[] = [
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'vehiclePlate', label: '发车车牌', minWidth: 125 }, { key: 'driverName', label: '发车司机', minWidth: 100 },
{ key: 'warehouseName', label: '起运仓', minWidth: 150 }, { key: 'destinations', label: '目的地', minWidth: 180, slotName: 'destinations' },
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'physicalPallets', label: '实物托盘', width: 100 },
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'physicalPallets', label: '实物托盘', width: 100, formatter: (row: TripOperationsRow) => row.physicalPallets ?? '—' },
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' },
{ key: 'completedAt', label: '货物结案(UTC)', minWidth: 190, defaultVisible: false },
{ key: 'palletSpaces', label: '标准托位', width: 100, defaultVisible: false }, { key: 'ownPackages', label: '自有箱数', width: 100, defaultVisible: false }, { key: 'externalPackages', label: '外接箱数', width: 100, defaultVisible: false },
{ key: 'palletSpaces', label: '标准托位', width: 100, defaultVisible: false, formatter: (row: TripOperationsRow) => row.palletSpaces ?? '—' }, { key: 'ownPackages', label: '自有箱数', width: 100, defaultVisible: false }, { key: 'externalPackages', label: '外接箱数', width: 100, defaultVisible: false },
{ key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance', defaultVisible: false }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel', defaultVisible: false },
{ key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance', defaultVisible: false }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost', defaultVisible: false }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated', defaultVisible: false }]
function range(): TripOperationsQuery {
@@ -4,7 +4,7 @@ import type { Resources, ReviewAttachments, Version } from '../dispatchContracts
export type TransportType = 'PICKUP' | 'LOCAL_TRUCK'
export const transportLabels = { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; completionEvidence: PickupEvidence[]; dispatchRecordVersion: Version; resources: Resources | null }
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; departureEvidence?: PickupEvidence[]; completionEvidence: PickupEvidence[]; dispatchRecordVersion: Version; resources: Resources | null }
export type TransportAssignment = TripAssignment & { dispatchRecordVersion?: Version; reviewPhotos?: ReviewAttachments; masterLicenseFileId?: string | null }
export interface TruckCandidate { customerId: string; orderNo: string; origin: string; originAddress: string; destination: string; destinationAddress: string; palletCount: number; packageCount: number }
export interface TruckPlan { customerId: string; orderNo: string; warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; idempotencyKey: string }
@@ -13,7 +13,7 @@ const base = '/platform/v1/last-leg/dispatches/transport-tasks'
const path = (number: string) => `${base}/${encodeURIComponent(number)}`
async function get<T>(url: string, params?: object) { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
async function post<T>(url: string, body: object) { return unwrapApiResponse(await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
const normalize = (value: WireDetail): TransportDetail => ({ ...value, execution: normalizeTripDetail(value.execution), pickups: value.pickups.map(row => ({ ...row, packageCount: Number(row.packageCount), actualPackages: row.actualPackages == null ? null : Number(row.actualPackages) })) })
const normalize = (value: WireDetail): TransportDetail => ({ ...value, departureEvidence: value.departureEvidence || [], completionEvidence: value.completionEvidence || [], execution: normalizeTripDetail(value.execution), pickups: value.pickups.map(row => ({ ...row, packageCount: Number(row.packageCount), actualPackages: row.actualPackages == null ? null : Number(row.actualPackages) })) })
export const getTransport = (number: string) => get<WireDetail>(path(number)).then(normalize)
export const pickupCandidates = () => get<{ records: PickupCandidate[]; total: number | string }>(`${base}/pickup-candidates`, { page: 1, size: 100 })
export const truckCandidates = () => get<TruckCandidate[]>(`${base}/truck-candidates`)
@@ -151,16 +151,17 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
.dispatch-evidence--readonly .dispatch-evidence__actions { width: 76px; }
.dispatch-evidence > .el-button { margin-top: var(--admin-space-3); }
.evidence-input { display: none; }
.dispatch-evidence--photos { min-width: 0; }
.evidence-photo-card { margin: 0; text-align: center; }
.dispatch-evidence--photos { min-width: 0; display: flex; flex-direction: column; }
.evidence-photo-card { margin: 0; text-align: center; flex: 1; display: flex; flex-direction: column; }
.evidence-photo-list { display: grid; gap: var(--admin-space-3); }
.evidence-photo-name { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
.evidence-photo-name { margin: var(--admin-space-3) 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.evidence-photo-actions { justify-content: center; }
.dispatch-evidence--photos > .evidence-upload-button { width: 100%; }
.evidence-photo-preview, .evidence-photo-empty { box-sizing: border-box; width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); color: var(--el-text-color-secondary); }
.evidence-photo-preview { padding: 0; cursor: pointer; }
.evidence-photo-preview { padding: 0; cursor: pointer; background: var(--el-fill-color-lighter); overflow: hidden; }
.evidence-photo-empty { background: var(--el-fill-color-lighter); }
.evidence-photo-preview img { width: 100%; height: 100%; object-fit: contain; }
.evidence-photo-card figcaption { margin-top: var(--admin-space-3); font-weight: 600; }
.evidence-photo-card figcaption { margin-top: auto; padding-top: var(--admin-space-3); font-weight: 600; }
.evidence-preview { display: grid; place-items: center; height: min(60vh, 600px); min-width: 0; }
.evidence-image { display: block; width: 100%; height: 100%; min-height: 0; object-fit: contain; }
</style>
@@ -12,13 +12,25 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn() }))
import * as transportApi from '../api/transportTasks'
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
async function open(action: DispatchAction, allowed = true) {
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
async function open(action: DispatchAction, allowed = true, overrides: Partial<DispatchDetail> = {}) {
vi.mocked(api.getDispatch).mockResolvedValue(detail({ ...overrides, actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
return { wrapper, state: wrapper.vm as unknown as State }
}
beforeEach(() => { vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
it('shows only source pickup destinations in warehouse-transfer review submission', async () => {
vi.mocked(transportApi.getTransport).mockResolvedValue({ dispatchNo: 'PC/1', taskType: 'PICKUP', execution: {} as never, customerId: null, orderNo: null, pickups: [{ pickupAddress: 'US-LAX-01 · 123 Warehouse Ave' }, { pickupAddress: 'US-LAX-01 · 123 Warehouse Ave' }] as never, completionEvidence: [], dispatchRecordVersion: 0, resources: null })
const { wrapper } = await open('SUBMIT_REVIEW', true, { taskType: 'PICKUP', dispatchInfo: { ...detail().dispatchInfo, route: '到仓取货 · 1单 · US-LAX-01' } })
const text = wrapper.getComponent({ name: 'ElDialogContent' }).text()
expect(text).toContain('取货目的地')
expect(text).toContain('US-LAX-01 · 123 Warehouse Ave')
expect(text).not.toContain('起运地')
expect(transportApi.getTransport).toHaveBeenCalledExactlyOnceWith('PC/1')
wrapper.unmount()
})
it('confirms scanned cargo without requesting measurements and preserves native version zero', async () => {
const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
@@ -10,6 +10,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import { getTransport } from '../api/transportTasks'
import type { MarkDispatchException, DispatchExceptionType, DispatchReturnDirection, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
import { DispatchInputError, requireVersion } from '../dispatchContracts'
@@ -32,6 +33,7 @@ const currentNo = computed(() => pending.value?.dispatchNo || props.dispatchNo)
const currentOperation = computed(() => pending.value ? pending.value.operation : props.operation)
const route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
const pickupDestinations = ref<string[]>([])
const factsStale = ref(false)
const preview = ref<LoadingPreview | null>(null)
const loading = computed(() => working.value)
@@ -66,7 +68,7 @@ const cargoEntryMessage = '当前入口不支持调整已建排车货物。'
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
const vehicleEditor = computed(() => currentOperation.value !== 'ROUTE' && (currentAction.value === 'EDIT' || props.action === 'EDIT' && currentAction.value === 'SUBMIT_REVIEW'))
const submitting = ref(false)
const width = computed(() => vehicleEditor.value ? '1120px' : isReview.value ? '1100px' : currentAction.value === 'MARK_EXCEPTION' ? '760px' : '560px')
const width = computed(() => vehicleEditor.value ? '1120px' : isReview.value ? '1100px' : currentAction.value === 'SUBMIT_REVIEW' ? '820px' : currentAction.value === 'MARK_EXCEPTION' ? '760px' : '560px')
const frozen = computed(() => submitting.value || busy.value || Boolean(pending.value))
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
@@ -75,7 +77,7 @@ async function load() {
const sequence = ++generation, no = currentNo.value, action = currentAction.value
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { working.value = false; return }
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null
route.value = null; stops.value = []
route.value = null; stops.value = []; pickupDestinations.value = []
try {
if (cargoAction.value) return
if (action === 'CREATE') throw new DispatchInputError('请从排车列表进入新建排车')
@@ -83,6 +85,13 @@ async function load() {
if (sequence !== generation) return
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
detail.value = value
// 仓储转运取货显示原单取货地址,不把排车摘要当作起运地。
if (action === 'SUBMIT_REVIEW' && value.taskType === 'PICKUP') {
const transport = await getTransport(no)
if (sequence !== generation) return
if (transport.dispatchNo !== no) throw new DispatchInputError('取货资料与排车单不一致')
pickupDestinations.value = [...new Set(transport.pickups.map(row => row.pickupAddress?.trim()).filter((address): address is string => Boolean(address)))]
}
form.maintenance = value.vehicleInfo.maintenance ? { ...value.vehicleInfo.maintenance } : blankVehicleMaintenance()
fillDestinationAppointments(value)
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
@@ -138,7 +147,11 @@ function payload(): object {
if (!measurements.value.length || measurements.value.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new DispatchInputError('托盘卸货站尚未匹配,请调度核对路线')
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, remark: form.remark || null } satisfies LoadingComplete
}
case 'SUBMIT_REVIEW': return { ...common, remark: null }
case 'SUBMIT_REVIEW':
if (!detail.value.vehicleInfo.vehicle?.vehicleId || !detail.value.vehicleInfo.driver?.driverId) {
throw new DispatchInputError('车辆信息尚未维护完整,请先点击“维护车辆”并保存车辆、司机后再提交审核')
}
return { ...common, remark: null }
default: return { ...common, remark: form.remark || null }
}
}
@@ -257,6 +270,18 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
</template>
</DispatchVehicleMaintenanceForm>
</template>
<template v-else-if="currentAction === 'SUBMIT_REVIEW'">
<section class="density-dialog-section" data-tone="source" aria-label="提交审核依据">
<h3 class="density-dialog-section__title">审核依据</h3>
<table class="density-table"><tbody>
<tr><th scope="row">车牌号</th><td>{{ detail.vehicleInfo.vehicle?.plate || '未维护' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '未维护' }}</td><th scope="row">联系电话</th><td>{{ detail.vehicleInfo.driver?.phone || '—' }}</td></tr>
<tr v-if="detail.taskType === 'PICKUP'"><th scope="row">取货目的地</th><td colspan="5"><div v-for="address in pickupDestinations" :key="address">{{ address }}</div><span v-if="!pickupDestinations.length">—</span></td></tr>
<tr v-else><th scope="row">起运地</th><td>{{ detail.dispatchInfo.route?.split(' → ')[0] || '—' }}</td><th scope="row">目的地</th><td colspan="3">{{ detail.dispatchInfo.route?.split(' → ').slice(1).join(' → ') || '—' }}</td></tr>
<tr><th scope="row">计划发车</th><td>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td><th scope="row">预计送达</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td><th scope="row">货量</th><td>{{ detail.counts.palletCount }} 托 / {{ detail.counts.packageCount }} 箱</td></tr>
</tbody></table>
<el-alert v-if="!detail.vehicleInfo.vehicle?.vehicleId || !detail.vehicleInfo.driver?.driverId" title="车辆信息尚未维护完整,请先点击“维护车辆”并保存车辆、司机后再提交审核。" type="warning" :closable="false" show-icon />
</section>
</template>
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据">
<h3 class="density-dialog-section__title">车辆与货物</h3>
@@ -278,7 +303,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
</template>
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
<section v-if="isReview" class="density-dialog-section" data-tone="source" aria-label="审核照片">
<section v-if="isReview || currentAction === 'SUBMIT_REVIEW' && !vehicleEditor" class="density-dialog-section" data-tone="source" aria-label="审核照片">
<h3 class="density-dialog-section__title">车辆司机照片</h3>
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
@@ -1,24 +1,126 @@
import { mount, flushPromises } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
import { beforeEach, expect, it, vi } from 'vitest'
import TransportExecutionPanel from './TransportExecutionPanel.vue'
import { assignTransport, commandTransport, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
import { assignTransport, commandTransport, downloadTransportEvidence, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
import { getDriver } from '@/modules/overseas-master-data'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), getDriver: vi.fn(), downloadDriverLicense: vi.fn(), pickupStatusLabels: { ASSIGNED: '待取货', PICKED: '已取货', ARRIVED: '已到仓' } }))
vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn(), assignTransport: vi.fn(), transportOccupancy: vi.fn(), cancelTransport: vi.fn(), releaseTransport: vi.fn(), commandTransport: vi.fn(), uploadTransport: vi.fn(), downloadTransportEvidence: vi.fn(), transportLabels: { PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' } }))
const facts = (arriving = false): TransportDetail => ({ dispatchNo: 'PC1', taskType: 'PICKUP', execution: { trip: { status: arriving ? 'IN_TRANSIT' : 'PLANNED', recordVersion: 4, warehouseName: '洛杉矶仓', vehicleId: '1', vehiclePlate: 'CA-1', driverId: '2', driverName: '司机甲', driverPhone: '555' } }, pickups: [{ customerId: 'C1', transferNo: 'WT1', packageCount: 5, actualPackages: arriving ? 5 : null, status: arriving ? 'PICKED' : 'ASSIGNED', evidence: [] }], completionEvidence: [] }) as unknown as TransportDetail
const options = { global: { plugins: [ElementPlus] } }
it.each([false, true])('requires this truck stage own photo before footer completion (%s)', async arriving => {
const warning = vi.spyOn(ElMessage, 'warning').mockImplementation(() => ({ close: vi.fn() }))
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm')
const value = facts(arriving); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []
vi.mocked(getTransport).mockResolvedValue(value)
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: arriving ? 'DELIVERING' : 'LOADING' } }); await flushPromises()
expect(wrapper.vm.completionVisible).toBe(true)
expect(wrapper.vm.completionDisabled).toBe(false)
expect(wrapper.findAll('button').some(button => ['完成', '完成本单'].includes(button.text()))).toBe(false)
await wrapper.vm.submitCompletion()
expect(warning).toHaveBeenCalledWith(arriving ? '请至少上传一张送达照片' : '请至少上传一张取货照片')
expect(confirm).not.toHaveBeenCalled(); expect(commandTransport).not.toHaveBeenCalled()
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true })
await input.trigger('change'); await flushPromises()
await wrapper.vm.submitCompletion(); await flushPromises()
expect(commandTransport).toHaveBeenCalledWith('PC1', `truck/${arriving ? 'complete' : 'depart'}`, expect.objectContaining({ recordVersion: 4, reason: arriving ? '确认已在 送达并完成本土卡车订单?' : '确认 取货完成', evidenceFileIds: ['88'] }))
wrapper.unmount(); warning.mockRestore(); confirm.mockRestore()
})
it('confirms the B point action as pickup completion and explains the next stage', async () => {
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm')
const value = facts(); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []
vi.mocked(getTransport).mockResolvedValue(value)
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'LOADING' } }); await flushPromises()
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true })
await input.trigger('change'); await flushPromises()
await wrapper.vm.submitCompletion(); await flushPromises()
expect(confirm).toHaveBeenCalledWith('', '确认取货完成', expect.any(Object))
wrapper.unmount(); confirm.mockRestore()
})
beforeEach(() => {
can.mockReturnValue(true)
vi.clearAllMocks(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.stubGlobal('URL', { ...URL, createObjectURL: vi.fn(() => 'blob:truck-photo'), revokeObjectURL: vi.fn() })
vi.mocked(getTransport).mockResolvedValue(facts())
vi.mocked(getDriver).mockResolvedValue({ id: '2', licenseFileId: '33' } as Awaited<ReturnType<typeof getDriver>>)
vi.mocked(uploadTransport).mockResolvedValue({ fileId: '88', fileName: '现场.jpg', contentType: 'image/jpeg', sizeBytes: 10 })
})
it('keeps the footer action visible without departure permission and explains why submission is blocked', async () => {
can.mockImplementation(code => code !== 'platform:overseas-warehouse:dispatch:depart')
const warning = vi.spyOn(ElMessage, 'warning').mockImplementation(() => ({ close: vi.fn() }))
const value = facts(); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []
vi.mocked(getTransport).mockResolvedValue(value)
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'LOADING' } }); await flushPromises()
expect(wrapper.vm.completionVisible).toBe(true)
await wrapper.vm.submitCompletion()
expect(warning).toHaveBeenLastCalledWith('请至少上传一张取货照片')
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true })
await input.trigger('change'); await flushPromises()
await wrapper.vm.submitCompletion()
expect(warning).toHaveBeenLastCalledWith('当前账号缺少确认发车权限,请联系管理员开通后再完成取货')
expect(commandTransport).not.toHaveBeenCalled()
wrapper.unmount(); warning.mockRestore()
})
it('shows B point pickup photos inline and accepts multiple files', async () => {
const value = facts(); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.dispatchRecordVersion = 9
value.execution.trip.stops = []; value.execution.trip.vehicleId = '1'; value.execution.trip.driverId = '2'
vi.mocked(getTransport).mockResolvedValue(value)
vi.mocked(uploadTransport).mockResolvedValueOnce({ fileId: '88', fileName: '现场1.jpg', contentType: 'image/jpeg', sizeBytes: 10 })
.mockResolvedValueOnce({ fileId: '89', fileName: '现场2.jpg', contentType: 'image/jpeg', sizeBytes: 10 })
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'LOADING' } }); await flushPromises()
const input = wrapper.get('input[type="file"]')
expect(input.attributes('multiple')).toBeDefined()
Object.defineProperty(input.element, 'files', { value: [new File(['1'], '现场1.jpg', { type: 'image/jpeg' }), new File(['2'], '现场2.jpg', { type: 'image/jpeg' })], configurable: true })
await input.trigger('change'); await flushPromises()
expect(wrapper.findAll('.truck-evidence-item img')).toHaveLength(2)
expect(wrapper.text()).toContain('现场1.jpg'); expect(wrapper.text()).toContain('现场2.jpg')
expect(vi.mocked(uploadTransport)).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('shows B point evidence separately and keeps delivery uploads in the C point section', async () => {
const value = facts(true); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []
value.departureEvidence = [{ fileId: '70', fileName: '取货.jpg', contentType: 'image/jpeg', sizeBytes: 10 }]
value.completionEvidence = []
vi.mocked(getTransport).mockResolvedValue(value)
vi.mocked(downloadTransportEvidence).mockResolvedValue(new Blob(['photo'], { type: 'image/jpeg' }))
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'DELIVERING' } }); await flushPromises()
const pickup = wrapper.get('[aria-label="取货照片"]')
const delivery = wrapper.get('[aria-label="送达照片上传"]')
expect(pickup.text()).toContain('取货.jpg')
expect(pickup.findAll('input[type="file"]')).toHaveLength(0)
expect(pickup.findAll('.truck-evidence-delete')).toHaveLength(0)
expect(delivery.text()).toContain('上传送达照片')
expect(delivery.findAll('.truck-evidence-item')).toHaveLength(0)
expect(delivery.get('input[type="file"]').attributes('multiple')).toBeDefined()
wrapper.unmount()
})
const photoOptions = { global: { plugins: [ElementPlus], stubs: { DispatchEvidenceFiles: true } } }
it('requires uploaded truck license photos and hides pickup evidence during vehicle maintenance', async () => {
const value = facts(); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []; value.dispatchRecordVersion = 9
vi.mocked(getTransport).mockResolvedValue(value)
vi.mocked(assignTransport).mockResolvedValue(value)
const wrapper = mount(TransportExecutionPanel, { ...photoOptions, props: { dispatchNo: 'PC1', editResources: true } }); await flushPromises()
const cards = wrapper.findAllComponents(DispatchEvidenceFiles)
expect(cards).toHaveLength(3)
expect(cards[2]!.props('referenceIds')).toEqual([])
expect(cards[2]!.props('modelValue')).toEqual([])
expect(wrapper.text()).not.toContain('取货照片')
cards[0]!.vm.$emit('update:modelValue', ['21']); cards[1]!.vm.$emit('update:modelValue', ['22']); await flushPromises()
const save = wrapper.findAll('button').find(button => button.text() === '保存车辆司机')!
expect(save.attributes('disabled')).toBeDefined()
cards[2]!.vm.$emit('update:modelValue', ['23']); await flushPromises()
await save.trigger('click'); await flushPromises()
expect(assignTransport).toHaveBeenCalledWith('PC1', expect.objectContaining({ masterLicenseFileId: null, reviewPhotos: { vehicleFileIds: ['21'], driverPhotoFileIds: ['22'], licenseFileIds: ['23'] } }))
wrapper.unmount()
})
it('refreshes the selected driver master license when reopening resource maintenance', async () => {
const value = facts()
value.resources = { vehicle: null, driver: { driverId: '2', evidence: { photoFileIds: ['22'], fileIds: ['30'], masterLicenseFileId: '30' } } } as TransportDetail['resources']
@@ -126,7 +228,7 @@ it('keeps pickup addresses and contacts on their own transfer rows', async () =>
value.pickups.push({ ...value.pickups[0]!, transferNo: 'WT2', customerId: 'C2', pickupAddress: '200 Test Pickup Road', contactName: '取货联系人乙', contactPhone: '0000000002' })
vi.mocked(getTransport).mockResolvedValue(value)
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', readonly: true } }); await flushPromises()
const rows = wrapper.get('[aria-label="取货明细"]').findAll('tbody tr')
const rows = wrapper.get('[aria-label="取货明细"]').findAll('.pickup-detail-card')
expect(rows[0]!.text()).toContain('100 Test Pickup Avenue')
expect(rows[0]!.text()).toContain('取货联系人甲')
expect(rows[0]!.text()).toContain('0000000001')
@@ -137,3 +239,24 @@ it('keeps pickup addresses and contacts on their own transfer rows', async () =>
expect(wrapper.findAll('button')).toHaveLength(0)
wrapper.unmount()
})
it('renders every pickup and arrival photo inline after completion and has no resource ending actions', async () => {
const value = facts(true); value.execution.trip.status = 'COMPLETED'
value.pickups[0]!.status = 'ARRIVED'
value.pickups[0]!.evidence = [
{ fileId: '101', fileName: '取货一.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'PICKUP' },
{ fileId: '102', fileName: '取货二.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'PICKUP' },
{ fileId: '103', fileName: '送达一.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'ARRIVAL' },
{ fileId: '104', fileName: '送达二.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'ARRIVAL' },
{ fileId: '105', fileName: '送达三.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'ARRIVAL' },
]
vi.mocked(getTransport).mockResolvedValue(value)
vi.mocked(downloadTransportEvidence).mockResolvedValue(new Blob(['photo'], { type: 'image/jpeg' }))
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'DELIVERED', readonly: true } }); await flushPromises()
expect(wrapper.get('[aria-label="取货照片"]').findAll('img')).toHaveLength(2)
expect(wrapper.get('[aria-label="送达照片"]').findAll('img')).toHaveLength(3)
for (const file of value.pickups[0]!.evidence) expect(downloadTransportEvidence).toHaveBeenCalledWith('PC1', file.fileId, expect.objectContaining({ customerId: 'C1', transferNo: 'WT1' }))
expect(wrapper.text()).not.toContain('结束车辆任务'); expect(wrapper.text()).not.toContain('结束司机任务')
const revoke = vi.mocked(URL.revokeObjectURL); revoke.mockClear()
wrapper.unmount(); expect(revoke).toHaveBeenCalledTimes(5)
})
@@ -1,10 +1,10 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, reactive, ref, watch } from 'vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onBeforeRouteLeave } from 'vue-router'
import { Truck, Package, RefreshCw } from '@lucide/vue'
import { Truck, Package, RefreshCw, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -12,32 +12,63 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { listVehicles, listDrivers, getDriver, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import { getTransport, assignTransport, transportOccupancy, cancelTransport, releaseTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
import TransportPhotoGallery from './TransportPhotoGallery.vue'
import { getTransport, assignTransport, transportOccupancy, cancelTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
import { dispatchActionPermissions as permission } from '../dispatchPresentation'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean; pickupReviewState?: string; editResources?: boolean; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>()
const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean; pickupReviewState?: string; pickupStage?: string; editResources?: boolean; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore(), detail = ref<TransportDetail | null>(null), error = ref<ApiErrorDetails | null>(null), busy = ref(false)
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), vehicleId = ref(''), driverId = ref(''), reason = ref('')
const occupancy = ref<Record<string, boolean>>({}), candidateBusy = ref(false)
const vehiclePhotos = ref<string[]>([]), driverPhotos = ref<string[]>([]), licensePhotos = ref<string[]>([])
const masterLicense = ref<{ driverId: string; fileId: string } | null>(null), licenseLoading = ref(false)
const photoBusy = reactive({ vehicle: false, driver: false, license: false })
const activeMasterLicense = computed(() => licensePhotos.value.length ? null : masterLicense.value)
const photosComplete = computed(() => detail.value?.taskType !== 'PICKUP' || !!(vehiclePhotos.value.length && driverPhotos.value.length && (licensePhotos.value.length || activeMasterLicense.value)))
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
const activeMasterLicense = computed(() => detail.value?.taskType === 'PICKUP' && !licensePhotos.value.length ? masterLicense.value : null)
const photosComplete = computed(() => !['PICKUP', 'LOCAL_TRUCK'].includes(detail.value?.taskType || '') || !!(vehiclePhotos.value.length && driverPhotos.value.length && (licensePhotos.value.length || activeMasterLicense.value)))
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([])
const truckPickupEvidence = ref<PickupEvidence[]>([]), truckDeliveryEvidence = ref<PickupEvidence[]>([])
const truckPreviewUrls = ref<Record<string, string>>({})
const arrivalMode = ref(false)
const validPickupQuantity = computed(() => typeof actual.value === 'number' && Number.isInteger(actual.value) && actual.value >= 1 && actual.value <= 5000)
const editingResources = ref(props.editResources === true)
const pickupPhotos = (row: PickupTask, phase: 'PICKUP' | 'ARRIVAL') => row.evidence.filter(file => ((file as PickupEvidence & { phase?: string }).phase || 'PICKUP') === phase)
const pickupStage = computed(() => trip.value?.status === 'COMPLETED' ? '取货派送完成' : trip.value?.status === 'CANCELLED' ? '已取消' : detail.value?.pickups.some(row => row.status === 'ASSIGNED') ? props.pickupApproved ? '装货中' : ({ DRAFT: '待提交审核', DISPATCH_PENDING: '待调度审核', LOADING_PENDING: '待理货审核', REJECTED: '审核退回' }[props.pickupReviewState || 'DRAFT'] || '待审核') : '派送中')
const releaseOpen = ref(false), releaseKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), endLocation = ref(''), actualEnd = ref('')
const pending = ref<{ suffix: string; body: object; kind?: 'cancel' | 'release' } | null>(null)
const pending = ref<{ suffix: string; body: object; kind?: 'cancel' } | null>(null)
const trip = computed(() => detail.value?.execution.trip)
const editable = computed(() => !props.readonly && access.can(permission.EDIT))
const canChangeResources = computed(() => trip.value?.status === 'PLANNED' && !detail.value?.pickups.some(row => row.status !== 'ASSIGNED'))
const canChangeResources = computed(() => trip.value?.status === 'PLANNED' && props.pickupStage !== 'LOADING' && !detail.value?.pickups.some(row => row.status !== 'ASSIGNED'))
const resourceFormOpen = computed(() => canChangeResources.value && editable.value && (editingResources.value || !trip.value?.vehicleId || !trip.value?.driverId))
const locked = computed(() => busy.value || !!pending.value || licenseLoading.value || Object.values(photoBusy).some(Boolean))
const completionAction = computed(() => {
if (detail.value?.taskType !== 'LOCAL_TRUCK' || !editable.value || resourceFormOpen.value || !props.pickupApproved || !trip.value) return null
if (props.pickupStage === 'LOADING' && trip.value.status === 'PLANNED') {
return { action: 'depart' as const, label: '完成', missingPhotoMessage: '请至少上传一张取货照片' }
}
if (props.pickupStage === 'DELIVERING' && trip.value.status === 'IN_TRANSIT') {
return { action: 'complete' as const, label: '完成本单', missingPhotoMessage: '请至少上传一张送达照片' }
}
return null
})
const completionVisible = computed(() => !!completionAction.value)
const completionText = computed(() => completionAction.value?.label || '完成')
const completionLoading = computed(() => busy.value && !!pending.value && ['truck/depart', 'truck/complete'].includes(pending.value.suffix))
const completionDisabled = computed(() => locked.value || (completionAction.value?.action === 'depart' && (!trip.value?.vehicleId || !trip.value?.driverId)))
async function submitCompletion() {
const action = completionAction.value
if (!action || completionDisabled.value) return
const evidence = action.action === 'depart' ? truckPickupEvidence.value : truckDeliveryEvidence.value
if (!evidence.length) {
ElMessage.warning(action.missingPhotoMessage)
return
}
if (action.action === 'depart' && !access.can(permission.DEPART)) {
ElMessage.warning('当前账号缺少确认发车权限,请联系管理员开通后再完成取货')
return
}
await command(action.action)
}
defineExpose({ completionVisible, completionText, completionLoading, completionDisabled, submitCompletion })
/** 本车已保存的照片随资源带出;换车不能继续显示原车照片。 */
function restoreVehiclePhotos() {
const saved = detail.value?.resources?.vehicle
@@ -49,8 +80,9 @@ watch(() => [driverId.value, resourceFormOpen.value, detail.value] as const, asy
let current = true
onCleanup(() => { current = false })
licenseLoading.value = false
if (!open || detail.value?.taskType !== 'PICKUP') return
const saved = detail.value.resources?.driver
const transport = detail.value
if (!open || !transport || !['PICKUP', 'LOCAL_TRUCK'].includes(transport.taskType)) return
const saved = transport.resources?.driver
const same = saved?.driverId === id ? saved.evidence : null
driverPhotos.value = [...(same?.photoFileIds || [])]
licensePhotos.value = (same?.fileIds || []).filter(file => file !== same?.masterLicenseFileId)
@@ -63,9 +95,28 @@ watch(() => [driverId.value, resourceFormOpen.value, detail.value] as const, asy
} catch (cause) { if (current) error.value = getApiErrorDetails(cause, '驾驶证带出失败,可重新读取或上传照片') }
finally { if (current) licenseLoading.value = false }
}, { immediate: true })
function clearTruckPreviewUrls() { Object.values(truckPreviewUrls.value).forEach(url => URL.revokeObjectURL(url)); truckPreviewUrls.value = {} }
async function refreshTruckPreviews(files: PickupEvidence[]) {
const results = await Promise.allSettled(files.map(async file => [file.fileId, URL.createObjectURL(await downloadTransportEvidence(props.dispatchNo, file.fileId))] as const))
const next: Record<string, string> = {}
results.forEach(result => { if (result.status === 'fulfilled') next[result.value[0]] = result.value[1] })
Object.values(truckPreviewUrls.value).forEach(url => URL.revokeObjectURL(url)); truckPreviewUrls.value = next
}
function setTruckPreview(fileId: string, file: File) {
const old = truckPreviewUrls.value[fileId]
if (old) URL.revokeObjectURL(old)
truckPreviewUrls.value = { ...truckPreviewUrls.value, [fileId]: URL.createObjectURL(file) }
}
function removeTruckEvidence(fileId: string, pickup: boolean) {
if (pickup) truckPickupEvidence.value = truckPickupEvidence.value.filter(file => file.fileId !== fileId)
else truckDeliveryEvidence.value = truckDeliveryEvidence.value.filter(file => file.fileId !== fileId)
const url = truckPreviewUrls.value[fileId]
if (url) URL.revokeObjectURL(url)
const next = { ...truckPreviewUrls.value }; delete next[fileId]; truckPreviewUrls.value = next
}
async function load() {
error.value = null
try { detail.value = await getTransport(props.dispatchNo); vehicleId.value = trip.value?.vehicleId || ''; driverId.value = trip.value?.driverId || ''; restoreVehiclePhotos() }
try { detail.value = await getTransport(props.dispatchNo); vehicleId.value = trip.value?.vehicleId || ''; driverId.value = trip.value?.driverId || ''; truckPickupEvidence.value = [...(detail.value.departureEvidence || [])]; truckDeliveryEvidence.value = [...(detail.value.completionEvidence || [])]; clearTruckPreviewUrls(); await refreshTruckPreviews([...truckPickupEvidence.value, ...truckDeliveryEvidence.value]); restoreVehiclePhotos() }
catch (cause) { error.value = getApiErrorDetails(cause, '运输任务加载失败') }
}
watch(() => props.dispatchNo, () => { detail.value = null; pending.value = null; void load() }, { immediate: true })
@@ -81,9 +132,10 @@ async function candidates() {
finally { candidateBusy.value = false }
}
async function assign() {
if (!trip.value || locked.value || !vehicleId.value || !driverId.value || !photosComplete.value) return
const transport = detail.value
if (!transport || !trip.value || locked.value || !vehicleId.value || !driverId.value || !photosComplete.value) return
busy.value = true; error.value = null
try { detail.value = await assignTransport(props.dispatchNo, { recordVersion: trip.value.recordVersion, vehicleId: vehicleId.value, driverId: driverId.value, reason: reason.value.trim() || '分配运输车辆及司机', ...(detail.value?.taskType === 'PICKUP' ? { dispatchRecordVersion: detail.value.dispatchRecordVersion, reviewPhotos: { vehicleFileIds: [...vehiclePhotos.value], driverPhotoFileIds: [...driverPhotos.value], licenseFileIds: [...licensePhotos.value] }, masterLicenseFileId: activeMasterLicense.value?.fileId || null } : {}) }); editingResources.value = false; emit('saved'); ElMessage.success('车辆司机及审核照片已保存') }
try { detail.value = await assignTransport(props.dispatchNo, { recordVersion: trip.value.recordVersion, vehicleId: vehicleId.value, driverId: driverId.value, reason: reason.value.trim() || '分配运输车辆及司机', ...(['PICKUP', 'LOCAL_TRUCK'].includes(transport.taskType) ? { dispatchRecordVersion: transport.dispatchRecordVersion, reviewPhotos: { vehicleFileIds: [...vehiclePhotos.value], driverPhotoFileIds: [...driverPhotos.value], licenseFileIds: [...licensePhotos.value] }, masterLicenseFileId: activeMasterLicense.value?.fileId || null } : {}) }); editingResources.value = false; emit('saved'); ElMessage.success('车辆司机及审核照片已保存') }
catch (cause) { error.value = getApiErrorDetails(cause, '保存未完成,已上传照片保留,请核对后重试') }
finally { pending.value = null; busy.value = false }
}
@@ -92,33 +144,37 @@ async function execute() {
const request = pending.value; busy.value = true; error.value = null
try {
if (request.kind === 'cancel') await cancelTransport(props.dispatchNo, request.body)
else if (request.kind === 'release') await releaseTransport(props.dispatchNo, request.body as Parameters<typeof releaseTransport>[1])
else await commandTransport(props.dispatchNo, request.suffix, request.body)
pending.value = null; pickupOpen.value = false; releaseOpen.value = false; await load(); emit('saved'); ElMessage.success('运输操作已保存')
pending.value = null; pickupOpen.value = false; await load(); emit('saved'); ElMessage.success('运输操作已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, '运输操作未完成') }
finally { pending.value = null; busy.value = false }
}
async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row?: PickupTask) {
if (!trip.value || locked.value) return
const message = { depart: '确认车辆司机已出发执行运输任务?', complete: '确认运输已实际完成?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: detail.value?.taskType === 'PICKUP' ? '确认取消未发车排车及本车全部对应转运单?车辆和司机占用将同时释放。' : '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
try { await ElMessageBox.confirm(message, '确认运输操作', { customClass: 'admin-density-confirm' }) } catch { return }
if (!row && action === 'depart' && !truckPickupEvidence.value.length) { ElMessage.warning('请至少上传一张取货照片'); return }
if (!row && action === 'complete' && !truckDeliveryEvidence.value.length) { ElMessage.warning('请至少上传一张送达照片'); return }
const message = { depart: '', complete: '确认已在 送达并完成本土卡车订单?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: detail.value?.taskType === 'PICKUP' ? '确认取消未发车排车及本车全部对应转运单?车辆和司机占用将同时释放。' : '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
const title = action === 'depart' ? '确认取货完成' : '确认运输操作'
try { await ElMessageBox.confirm(message, title, { customClass: 'admin-density-confirm' }) } catch { return }
const prefix = detail.value?.taskType === 'PICKUP' ? 'pickup' : 'truck'
const suffix = row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${action}` : `${prefix}/${action}`
pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || message, ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckEvidence.value.map(file => file.fileId) } : {}) } }
const commandReason = reason.value.trim() || (action === 'depart' && prefix === 'truck' ? '确认 取货完成' : message || '确认运输操作')
pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: commandReason, ...(prefix === 'truck' && action === 'depart' ? { evidenceFileIds: truckPickupEvidence.value.map(file => file.fileId) } : {}), ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckDeliveryEvidence.value.map(file => file.fileId) } : {}) } }
await execute()
}
/** 实际取货数量由现场填写,预报箱数不能自动作为实提事实。 */
function editPickup(row: PickupTask, arrival = false) { selected.value = row; arrivalMode.value = arrival; actual.value = undefined; evidence.value = []; pickupOpen.value = true }
async function download(file: PickupEvidence, row?: PickupTask) {
try { const blob = await downloadTransportEvidence(props.dispatchNo, file.fileId, row); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败') }
}
async function upload(event: Event, pickup: boolean) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; if (!file || locked.value) return
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); if (!files.length || locked.value) return
busy.value = true; error.value = null
try {
const row = selected.value, suffix = pickup && row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}` : 'truck'
const result = await uploadTransport(props.dispatchNo, suffix, file); (pickup ? evidence.value : truckEvidence.value).push(result)
for (const file of files) {
const result = await uploadTransport(props.dispatchNo, suffix, file)
if (pickup && row) evidence.value.push(result)
else if (trip.value?.status === 'PLANNED') { truckPickupEvidence.value.push(result); setTruckPreview(result.fileId, file) }
else { truckDeliveryEvidence.value.push(result); setTruckPreview(result.fileId, file) }
}
} catch (cause) { error.value = getApiErrorDetails(cause, '凭证上传失败') }
finally { pending.value = null; busy.value = false; input.value = '' }
}
@@ -128,12 +184,8 @@ async function picked() {
const common = { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), evidenceFileIds: evidence.value.map(file => file.fileId) }
pending.value = { suffix: `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${arrivalMode.value ? 'arrive' : 'picked'}`, body: arrivalMode.value ? { ...common, reason: reason.value.trim() || '上传到仓照片,确认取货派送完成' } : { ...common, actualPackages: actual.value, remark: reason.value.trim() } }; await execute()
}
function openRelease(kind: 'VEHICLE' | 'DRIVER') { releaseKind.value = kind; endLocation.value = ''; actualEnd.value = ''; releaseOpen.value = true }
async function release() {
if (!trip.value || !actualEnd.value || !endLocation.value.trim() || locked.value) return
pending.value = { suffix: 'release', kind: 'release', body: { recordVersion: trip.value.recordVersion, resourceKind: releaseKind.value, actualEndAt: actualEnd.value, endLocation: endLocation.value.trim(), vehicleStatus: releaseKind.value === 'VEHICLE' ? 'IN_SERVICE' : null, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || '确认资源实际任务结束' } }; await execute()
}
onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提交,确认离开?'))
onBeforeUnmount(clearTruckPreviewUrls)
</script>
<template>
<section class="transport-execution admin-density admin-workbench" aria-label="运输任务执行">
@@ -141,13 +193,10 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
<template v-if="detail && trip">
<header class="transport-toolbar">
<div class="transport-identity"><Truck :size="18" aria-hidden="true" /><strong>{{ transportLabels[detail.taskType] }}</strong><el-tag :type="trip.status === 'CANCELLED' ? 'info' : trip.status === 'COMPLETED' ? 'success' : 'primary'">{{ detail.taskType === 'PICKUP' ? pickupStage : { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</el-tag></div>
<div class="transport-identity"><Truck :size="18" aria-hidden="true" /><strong>{{ transportLabels[detail.taskType] }}</strong><el-tag :type="trip.status === 'CANCELLED' ? 'info' : trip.status === 'COMPLETED' ? 'success' : 'primary'">{{ detail.taskType === 'PICKUP' ? pickupStage : { PLANNED: '待取货', IN_TRANSIT: '派送中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</el-tag></div>
<div v-if="!readonly" class="density-actions transport-actions">
<slot name="actions" />
<el-button v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'PLANNED' && access.can(permission.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId" @click="command('depart')">确认发车</el-button>
<el-button v-if="canChangeResources && access.can(permission.CANCEL)" type="danger" plain :disabled="locked" @click="command('cancel')">取消排车</el-button>
<el-button v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable" :disabled="locked || !truckEvidence.length" @click="command('complete')">确认运输完成</el-button>
<template v-if="trip.status === 'COMPLETED' && editable"><el-button v-if="!trip.vehicleRelease" :disabled="locked" @click="openRelease('VEHICLE')">结束车辆任务</el-button><el-button v-if="!trip.driverRelease" :disabled="locked" @click="openRelease('DRIVER')">结束司机任务</el-button></template>
<el-button link :disabled="locked" aria-label="刷新运输任务" @click="load"><RefreshCw :size="14" />刷新</el-button>
</div>
</header>
@@ -164,7 +213,7 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
<el-form v-if="resourceFormOpen" label-position="left" :disabled="locked" class="density-form transport-resource-form">
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
<section v-if="detail.taskType === 'PICKUP'" class="transport-review-photos" aria-label="调度审核照片">
<section v-if="['PICKUP', 'LOCAL_TRUCK'].includes(detail.taskType)" class="transport-review-photos" aria-label="调度审核照片">
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :disabled="locked" @busy="photoBusy.vehicle = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="locked" @busy="photoBusy.driver = $event" />
<DispatchEvidenceFiles photo-card v-model="licensePhotos" :dispatch-no="dispatchNo" kind="DRIVER" label="驾驶证照片" :master-license="activeMasterLicense" :reference-ids="activeMasterLicense ? [activeMasterLicense.fileId] : []" :disabled="locked" @busy="photoBusy.license = $event" />
@@ -175,22 +224,36 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
</section>
<section v-if="detail.taskType === 'PICKUP'" class="transport-pickups" aria-label="取货明细">
<div class="transport-section-heading"><h2 class="workbench-section-title"><Package :size="16" aria-hidden="true" />取货明细</h2><span class="workbench-meta">{{ detail.pickups.length }} 单 · 预报 {{ detail.pickups.reduce((total, row) => total + row.packageCount, 0) }} 箱</span></div>
<el-table :data="detail.pickups" size="small" class="density-table" empty-text="暂无取货明细">
<el-table-column v-if="!readonly" label="操作" width="160"><template #default="{ row }"><template v-if="editable && props.pickupApproved && ['PLANNED', 'IN_TRANSIT'].includes(trip.status) && ['ASSIGNED', 'PICKED'].includes(row.status)"><el-button v-if="row.status === 'ASSIGNED'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask)">上传装货照片</el-button><el-button v-if="row.status === 'PICKED' && trip.status === 'IN_TRANSIT'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask, true)">上传到仓照片</el-button></template><span v-else class="workbench-meta">—</span></template></el-table-column>
<el-table-column prop="transferNo" label="转运单号" width="210" />
<el-table-column label="取货地址" min-width="280"><template #default="{ row }">{{ row.pickupAddress || '—' }}</template></el-table-column>
<el-table-column label="联系人" min-width="120"><template #default="{ row }">{{ row.contactName || '—' }}</template></el-table-column>
<el-table-column label="联系电话" min-width="160"><template #default="{ row }">{{ row.contactPhone || '—' }}</template></el-table-column>
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="trip.status === 'CANCELLED' ? 'info' : row.status === 'ARRIVED' ? 'success' : 'primary'">{{ trip.status === 'CANCELLED' ? '已取消' : pickupStatusLabels[row.status] }}</el-tag></template></el-table-column>
<el-table-column label="预报/实际取货(箱)" width="180" align="right"><template #default="{ row }">{{ row.packageCount }} / {{ row.actualPackages ?? '—' }}</template></el-table-column>
<el-table-column label="取货照片" min-width="110"><template #default="{ row }"><el-button v-for="(file, index) in pickupPhotos(row as PickupTask, 'PICKUP')" :key="file.fileId" link @click="download(file, row as PickupTask)">照片{{ index + 1 }}</el-button><span v-if="!pickupPhotos(row as PickupTask, 'PICKUP').length">—</span></template></el-table-column>
<el-table-column label="到仓照片" min-width="110"><template #default="{ row }"><el-button v-for="(file, index) in pickupPhotos(row as PickupTask, 'ARRIVAL')" :key="file.fileId" link @click="download(file, row as PickupTask)">照片{{ index + 1 }}</el-button><span v-if="!pickupPhotos(row as PickupTask, 'ARRIVAL').length">—</span></template></el-table-column>
</el-table>
<div v-if="!detail.pickups.length" class="workbench-meta">暂无取货明细</div>
<div v-for="row in detail.pickups" :key="`${row.customerId}:${row.transferNo}`" class="pickup-detail-card">
<div class="pickup-detail-heading"><strong>{{ row.transferNo }}</strong><el-tag :type="trip.status === 'CANCELLED' ? 'info' : row.status === 'ARRIVED' ? 'success' : 'primary'">{{ trip.status === 'CANCELLED' ? '已取消' : pickupStatusLabels[row.status] }}</el-tag></div>
<dl class="transport-facts pickup-detail-facts">
<div><dt>取货地址</dt><dd>{{ row.pickupAddress || '—' }}</dd></div><div><dt>联系人</dt><dd>{{ row.contactName || '—' }}</dd></div><div><dt>联系电话</dt><dd>{{ row.contactPhone || '—' }}</dd></div><div><dt>预报 / 实际箱数</dt><dd>{{ row.packageCount }} / {{ row.actualPackages ?? '—' }}</dd></div>
<div><dt>取货照片</dt><dd><TransportPhotoGallery :dispatch-no="dispatchNo" :row="row as PickupTask" :files="pickupPhotos(row as PickupTask, 'PICKUP')" label="取货照片" /></dd></div><div><dt>送达照片</dt><dd><TransportPhotoGallery :dispatch-no="dispatchNo" :row="row as PickupTask" :files="pickupPhotos(row as PickupTask, 'ARRIVAL')" label="送达照片" /></dd></div>
</dl>
<div v-if="!readonly" class="pickup-detail-actions"><template v-if="editable && props.pickupApproved && ['PLANNED', 'IN_TRANSIT'].includes(trip.status) && ['ASSIGNED', 'PICKED'].includes(row.status)"><el-button v-if="row.status === 'ASSIGNED'" type="primary" plain :disabled="locked" @click="editPickup(row as PickupTask)">上传装货照片</el-button><el-button v-if="row.status === 'PICKED' && trip.status === 'IN_TRANSIT'" type="primary" plain :disabled="locked" @click="editPickup(row as PickupTask, true)">上传到仓照片</el-button></template></div>
</div>
</section>
<div v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable"><label>完成凭证 <input type="file" :disabled="locked" @change="upload($event, false)" /></label><p v-for="file in truckEvidence" :key="file.fileId">{{ file.fileName }}</p></div>
<el-button v-for="file in detail.completionEvidence || []" :key="file.fileId" link @click="download(file)">{{ file.fileName }}</el-button>
<section v-if="detail.taskType === 'LOCAL_TRUCK' && editable && !resourceFormOpen && props.pickupApproved && props.pickupStage === 'LOADING' && trip.status === 'PLANNED'" class="truck-evidence-panel" aria-label="取货照片上传">
<h2 class="workbench-section-title">取货照片</h2>
<label class="truck-evidence-upload">上传取货照片<input type="file" accept="image/*" multiple :disabled="locked" @change="upload($event, false)" /></label>
<div class="truck-evidence-list"><figure v-for="file in truckPickupEvidence" :key="`pickup-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`取货照片:${file.fileName}`" /><span v-else>照片加载中</span><el-button class="truck-evidence-delete" circle text type="danger" :disabled="locked" :aria-label="`删除取货照片 ${file.fileName}`" @click="removeTruckEvidence(file.fileId, true)"><Trash2 :size="16" /></el-button><figcaption>{{ file.fileName }}</figcaption></figure></div>
</section>
<div v-if="detail.taskType === 'LOCAL_TRUCK' && !props.readonly && props.pickupStage === 'DELIVERING' && trip.status === 'IN_TRANSIT' && truckPickupEvidence.length" class="truck-evidence-panel" aria-label="取货照片">
<h2 class="workbench-section-title">取货照片</h2>
<div class="truck-evidence-list"><figure v-for="file in truckPickupEvidence" :key="`pickup-readonly-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`取货照片:${file.fileName}`" /><span v-else>照片加载中</span><figcaption>{{ file.fileName }}</figcaption></figure></div>
</div>
<section v-if="detail.taskType === 'LOCAL_TRUCK' && editable && !resourceFormOpen && props.pickupApproved && props.pickupStage === 'DELIVERING' && trip.status === 'IN_TRANSIT'" class="truck-evidence-panel" aria-label="送达照片上传">
<h2 class="workbench-section-title">送达照片</h2>
<label class="truck-evidence-upload">上传送达照片<input type="file" accept="image/*" multiple :disabled="locked" @change="upload($event, false)" /></label>
<div class="truck-evidence-list"><figure v-for="file in truckDeliveryEvidence" :key="`delivery-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`送达照片:${file.fileName}`" /><span v-else>照片加载中</span><el-button class="truck-evidence-delete" circle text type="danger" :disabled="locked" :aria-label="`删除送达照片 ${file.fileName}`" @click="removeTruckEvidence(file.fileId, false)"><Trash2 :size="16" /></el-button><figcaption>{{ file.fileName }}</figcaption></figure></div>
</section>
<div v-if="detail.taskType === 'LOCAL_TRUCK' && (props.readonly || ['COMPLETED', 'CANCELLED'].includes(trip.status)) && (detail.departureEvidence?.length || detail.completionEvidence?.length)" class="truck-evidence-panel">
<template v-if="detail.departureEvidence?.length"><h2 class="workbench-section-title">取货照片</h2><div class="truck-evidence-list"><figure v-for="file in detail.departureEvidence || []" :key="`departure-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`取货照片:${file.fileName}`" /><span v-else>照片加载中</span><figcaption>{{ file.fileName }}</figcaption></figure>
</div></template><template v-if="detail.completionEvidence?.length"><h2 class="workbench-section-title">送达照片</h2><div class="truck-evidence-list"><figure v-for="file in detail.completionEvidence || []" :key="`completion-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`送达照片:${file.fileName}`" /><span v-else>照片加载中</span><figcaption>{{ file.fileName }}</figcaption></figure></div></template>
</div>
</template>
<AppDialog class="admin-density-dialog" v-model="pickupOpen" :title="arrivalMode ? '上传到仓照片' : '上传装货照片'" width="560px" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked">
<AppDialog class="admin-density-dialog" v-model="pickupOpen" :title="arrivalMode ? '上传到仓照片' : '上传装货照片'" width="min(760px, calc(100vw - 40px))" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked">
<p>{{ selected?.transferNo }}</p>
<el-form label-position="left" label-width="150px" :disabled="locked">
<el-form-item label="预报数量(箱)">{{ selected?.packageCount ?? '—' }}</el-form-item>
@@ -203,7 +266,6 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
</el-form>
<template #footer><el-button type="primary" :disabled="locked || !evidence.length || (!arrivalMode && !validPickupQuantity)" @click="picked">{{ arrivalMode ? '确认到仓' : '确认取货' }}</el-button></template>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="releaseOpen" :title="releaseKind === 'VEHICLE' ? '结束车辆任务' : '结束司机任务'" width="560px" :show-close="!locked" :close-on-click-modal="!locked"><el-form label-position="left" label-width="100px" :disabled="locked"><el-form-item label="实际结束时间" required><el-date-picker v-model="actualEnd" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="结束地点" required><el-input v-model="endLocation" /></el-form-item></el-form><template #footer><el-button type="primary" :disabled="locked || !actualEnd || !endLocation.trim()" @click="release">确认结束</el-button></template></AppDialog>
</section>
</template>
@@ -223,5 +285,24 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
.transport-review-photos { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-5); }
.transport-photo-hint, .transport-resource-save { grid-column: 1 / -1; }
.transport-pickups { display: grid; gap: var(--admin-space-4); min-width: 0; }
.pickup-detail-card { display: grid; gap: var(--admin-space-4); padding: var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--cc-radius-lg); background: var(--cc-surface); }
.pickup-detail-heading, .pickup-detail-actions { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
.pickup-detail-facts { margin: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.pickup-detail-facts > div { align-items: start; }
.pickup-detail-facts dd { display: flex; flex-wrap: wrap; gap: 4px 10px; }
.truck-evidence-panel { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-md); background: var(--cc-surface); }
.truck-evidence-panel > h2 { flex: 0 0 100%; margin: 0; }
.truck-evidence-panel label { display: inline-flex; align-items: center; gap: 10px; color: var(--cc-text); font-size: 13px; }
.truck-evidence-upload { padding: 8px 14px; border: 1px solid var(--el-color-primary); border-radius: var(--cc-radius-sm); color: var(--el-color-primary) !important; cursor: pointer; }
.truck-evidence-upload input[type='file'] { display: none; }
.truck-evidence-list { display: flex; flex-wrap: wrap; gap: 12px; color: var(--cc-muted); font-size: 12px; }
.truck-evidence-item { width: 132px; margin: 0; }
.truck-evidence-item { position: relative; }
.truck-evidence-item .el-image { display: block; width: 132px; height: 92px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-sm); background: var(--cc-surface-muted); cursor: pointer; }
.truck-evidence-item img { display: block; width: 100%; height: 100%; object-fit: cover; }
.truck-evidence-delete { position: absolute; z-index: 2; top: 4px; right: 4px; width: 26px; height: 26px; color: var(--el-color-danger) !important; background: rgb(255 255 255 / 88%) !important; box-shadow: 0 1px 4px rgb(0 0 0 / 18%); }
.truck-evidence-item figcaption { overflow: hidden; margin-top: 4px; text-overflow: ellipsis; white-space: nowrap; }
.truck-evidence-item .el-button { padding: 2px 0; }
@media (max-width: 760px) { .pickup-detail-facts { grid-template-columns: 1fr; } }
@media (max-width: 1100px) { .transport-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); } .transport-resource-form { grid-template-columns: minmax(200px, 1fr) minmax(200px, 1fr); } }
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import type { PickupEvidence, PickupTask } from '@/modules/overseas-master-data'
import { downloadTransportEvidence } from '../api/transportTasks'
const props = defineProps<{ dispatchNo: string; row: PickupTask; files: PickupEvidence[]; label: string }>()
const urls = ref<Record<string, string>>({}), failed = ref<string[]>([])
let generation = 0
function clear() { Object.values(urls.value).forEach(url => URL.revokeObjectURL(url)); urls.value = {} }
// 按客户及转运单读取每张原始照片;切单、刷新或卸载后丢弃迟到响应并释放预览。
async function load() {
const request = ++generation, number = props.dispatchNo, row = props.row
clear(); failed.value = []
await Promise.all(props.files.map(async file => {
try {
const blob = await downloadTransportEvidence(number, file.fileId, row)
if (request === generation) urls.value[file.fileId] = URL.createObjectURL(blob)
} catch { if (request === generation) failed.value.push(file.fileId) }
}))
}
watch(() => [props.dispatchNo, props.row.customerId, props.row.transferNo, props.files.map(file => file.fileId).join(',')], load, { immediate: true })
onBeforeUnmount(() => { generation++; clear() })
</script>
<template>
<div class="transport-photo-gallery" :aria-label="label">
<figure v-for="file in files" :key="file.fileId">
<el-image v-if="urls[file.fileId]" :src="urls[file.fileId]" :preview-src-list="files.map(item => urls[item.fileId]).filter((url): url is string => !!url)" :initial-index="files.filter(item => !!urls[item.fileId]).findIndex(item => item.fileId === file.fileId)" preview-teleported fit="contain" :alt="`${label}:${file.fileName}`" />
<el-button v-else-if="failed.includes(file.fileId)" link @click="load">加载失败,点击重试</el-button>
<span v-else>照片加载中</span>
<figcaption :title="file.fileName">{{ file.fileName }}</figcaption>
</figure>
<span v-if="!files.length">—</span>
</div>
</template>
<style scoped>
.transport-photo-gallery { display: flex; flex-wrap: wrap; gap: 12px; min-width: 0; }
figure { width: 132px; margin: 0; }
.el-image { display: block; width: 132px; height: 92px; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-sm); background: var(--cc-surface-muted); cursor: pointer; }
figcaption { overflow: hidden; margin-top: 4px; font-size: 12px; color: var(--cc-muted); text-overflow: ellipsis; white-space: nowrap; }
</style>
@@ -4,14 +4,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ElMessage } from 'element-plus'
import WarehouseTransferReviewDialog from './WarehouseTransferReviewDialog.vue'
import { getWarehouseTransfer, reviewWarehouseTransfer } from '../api/warehouseTransfers'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
const reviewedTransfer = { id: '1', transferNo: 'WT-1', packageCount: 2, needRelabel: false, status: 'WAIT_PDA_RECEIVING', items: [], cartons: [], pallets: [], relabelBindings: [] }
const reviewedTransfer = { id: '1', transferNo: 'WT-1', operatingWarehouseId: 'WH-1', packageCount: 2, needRelabel: false, status: 'WAIT_PDA_RECEIVING', items: [{ skuCode: 'SKU-1', packageCount: 2 }], cartons: [], pallets: [], relabelBindings: [] }
vi.mock('../api/warehouseTransfers', () => ({
getWarehouseTransfer: vi.fn(),
reviewWarehouseTransfer: vi.fn(),
}))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
const AppDialogStub = defineComponent({
name: 'AppDialog',
@@ -33,17 +35,21 @@ function mountDialog() {
}
describe('WarehouseTransferReviewDialog', () => {
beforeEach(() => { vi.clearAllMocks() })
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getWarehouseTransfer).mockResolvedValue(reviewedTransfer as never); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: 'WH-1', warehouseName: '洛杉矶操作仓' }] as never) })
it('shows a concise confirmation without fetching detail tables or review inputs', () => {
it('shows concise review facts without a detail table or editable review inputs', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.text()).toContain('仓储转运单审核 · WT-1')
expect(wrapper.text()).toContain('测试客户')
expect(wrapper.text()).toContain('预报箱数')
expect(wrapper.text()).toContain('洛杉矶操作仓')
expect(wrapper.text()).not.toContain('货物')
expect(wrapper.text()).not.toContain('审核通过后,转运单将进入海外仓收货打托作业。')
expect(wrapper.find('.back').text()).toBe('返回')
expect(wrapper.find('.approve').text()).toBe('审核通过')
expect(wrapper.find('textarea, input, table').exists()).toBe(false)
expect(getWarehouseTransfer).not.toHaveBeenCalled()
expect(getWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('20001', 'WT-1')
})
it('return closes without approving or rejecting the order', async () => {
@@ -1,16 +1,40 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import { reviewWarehouseTransfer } from '../api/warehouseTransfers'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { getWarehouseTransfer, reviewWarehouseTransfer, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
const props = defineProps<{ modelValue: boolean; customerId: string; customerName: string; transferNo: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; reviewed: [] }>()
const open = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
const saving = ref(false)
const loading = ref(false)
const detail = ref<WarehouseTransferDetailDto | null>(null)
const operatingWarehouseName = ref('')
async function loadDetail() {
if (!open.value || !props.customerId || !props.transferNo) return
loading.value = true
try { detail.value = await getWarehouseTransfer(props.customerId, props.transferNo) }
catch { detail.value = null }
try {
const warehouseId = detail.value?.operatingWarehouseId
if (warehouseId) {
const warehouses = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })
operatingWarehouseName.value = warehouses.find(item => String(item.id) === String(warehouseId))?.warehouseName || ''
} else operatingWarehouseName.value = ''
} catch { operatingWarehouseName.value = '' }
finally { loading.value = false }
}
watch(() => [open.value, props.customerId, props.transferNo] as const, ([visible]) => { if (visible) void loadDetail() }, { immediate: true })
const deliveryMethodLabel = computed(() => detail.value?.deliveryMethod === 'WAREHOUSE_PICKUP' ? '上门取货' : detail.value?.deliveryMethod === 'CUSTOMER_DELIVERY' ? '客户送货到仓' : detail.value?.deliveryMethod || '—')
const destinationLabel = computed(() => detail.value?.destinationWarehouseName || detail.value?.destinationWarehouseCode || detail.value?.fbaWarehouseCode || '—')
async function submit() {
if (saving.value || !open.value || !props.customerId || !props.transferNo) return
@@ -33,13 +57,35 @@ async function submit() {
v-model="open"
class="admin-density-dialog density-confirm-dialog admin-workbench is-dialog"
:title="`仓储转运单审核 · ${transferNo || '—'}`"
width="min(420px, calc(100vw - 28px))"
width="min(680px, calc(100vw - 28px))"
align-center
cancel-text="返回"
confirm-text="审核通过"
:confirm-loading="saving"
@confirm="submit"
>
<section class="density-dialog-section" data-tone="source" aria-label="转运审核对象"><p class="density-dialog-note">客户:{{ customerName || '—' }}</p></section>
<section class="density-dialog-section" data-tone="source" aria-label="转运审核明细">
<div v-if="loading" class="density-dialog-note">正在读取转运明细…</div>
<dl class="review-facts">
<div><dt>客户</dt><dd>{{ customerName || '—' }}</dd></div>
<div><dt>转运单号</dt><dd>{{ transferNo || '—' }}</dd></div>
<div><dt>送货方式</dt><dd>{{ deliveryMethodLabel }}</dd></div>
<div><dt>操作仓</dt><dd>{{ operatingWarehouseName || detail?.operatingWarehouseId || '—' }}</dd></div>
<div><dt>目的仓</dt><dd>{{ destinationLabel }}</dd></div>
<div><dt>预报箱数</dt><dd>{{ detail?.packageCount ?? '—' }} 箱</dd></div>
<div><dt>换标</dt><dd>{{ detail?.needRelabel ? '需要' : '不需要' }}</dd></div>
<div class="review-facts-wide"><dt>送货地址</dt><dd>{{ detail?.deliveryAddress || '—' }}</dd></div>
<div class="review-facts-wide"><dt>备注</dt><dd>{{ detail?.remark || '—' }}</dd></div>
</dl>
</section>
</AppDialog>
</template>
<style scoped>
.review-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; margin: 0; }
.review-facts > div { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 10px; min-width: 0; }
.review-facts dt { color: var(--cc-muted); }
.review-facts dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
.review-facts-wide { grid-column: 1 / -1; }
@media (max-width: 560px) { .review-facts { grid-template-columns: 1fr; } .review-facts-wide { grid-column: auto; } }
</style>
@@ -18,7 +18,7 @@ export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAtt
}
export const dispatchActionLabels: Record<DispatchAction, string> = {
MARK_EXCEPTION: '标记异常', CREATE: '新建排车', EDIT: '维护车辆', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
SUBMIT_REVIEW: '提交审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
RETURN_TO_REVIEW: '退回装车前重审', PICK: '拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '确认发车',
}
export const dispatchActionPermissions: Record<DispatchAction, string> = {
@@ -74,7 +74,7 @@ onBeforeUnmount(() => { generation++ })
<OverseasDetailShell workbench class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="appointmentReturn || inquiryReturnTarget(route.query) ? backLabel : undefined" :back-label="backLabel" domain="排车详情" :pending="false" :status-text="detail ? detail.status === 'CANCELLED' ? '已取消' : stageLabel(detail.stage) : '加载中'" @back="back">
<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="dispatch-detail" :aria-busy="loading">
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" readonly :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" :pickup-review-state="detail.reviewState" />
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" readonly :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" :pickup-review-state="detail.reviewState" :pickup-stage="detail.stage" />
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields vehicle-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
@@ -1,4 +1,5 @@
import { createPinia } from 'pinia'
import { defineComponent } from 'vue'
import ElementPlus, { ElMessageBox, ElSelect, ElTag } from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
@@ -23,13 +24,31 @@ vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.f
vi.mock('../api/transportTasks', () => ({ transportLabels: { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }, cancelTransport: vi.fn(), getTransport: vi.fn() }))
const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true, TransportTaskCreateDialog: true, TransportExecutionPanel: true } } }
beforeEach(() => { vi.clearAllMocks(); identity.id = crypto.randomUUID(); sessionStorage.clear(); can.mockReturnValue(true); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); localStorage.clear(); vi.mocked(listDispatches).mockResolvedValue(page()); vi.mocked(getDispatch).mockResolvedValue(detail()) })
it('places truck completion next to close in the outer dialog footer', async () => {
const submitCompletion = vi.fn()
const panel = defineComponent({
name: 'TransportExecutionPanel',
setup(_, { expose }) {
expose({ completionVisible: true, completionText: '完成', completionLoading: false, completionDisabled: false, submitCompletion })
return () => null
},
})
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'LOCAL_TRUCK', stage: 'LOADING', reviewState: 'APPROVED', actions: {} })]))
const wrapper = mount(DispatchesView, { global: { ...options.global, stubs: { ...options.global.stubs, TransportExecutionPanel: panel } } }); await flushPromises()
wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'transport'); await flushPromises()
const footer = wrapper.get('.app-dialog__footer')
expect(footer.findAll('button').map(button => button.text())).toEqual(['关闭', '完成'])
await footer.findAll('button')[1]!.trigger('click')
expect(submitCompletion).toHaveBeenCalledOnce()
wrapper.unmount()
})
it('offers pickup detail, transport vehicle maintenance and the shared review workflow', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions').map((item: { key: string }) => item.key)).toEqual(['EDIT', 'SUBMIT_REVIEW', 'CANCEL', 'detail'])
expect(row.findAll('button').map(button => button.text())).toEqual(['维护车辆', '提交调度审核', '详情', '更多'])
expect(row.findAll('button').map(button => button.text())).toEqual(['维护车辆', '提交审核', '详情', '更多'])
row.vm.$emit('action', 'EDIT'); await flushPromises()
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('dispatchNo')).toBe('PC/1')
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('editResources')).toBe(true)
@@ -52,7 +71,7 @@ it('confirms cancelling pickup and linked transfers using the transport version'
expect(listDispatches).toHaveBeenCalledTimes(2)
wrapper.unmount(); confirm.mockRestore()
})
it.each([['LOADING', '上传装货照片'], ['DELIVERING', '上传到仓照片'], ['DELIVERED', '结束资源任务']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => {
it.each([['LOADING', '上传装货照片'], ['DELIVERING', '上传到仓照片']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => {
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage, reviewState: 'APPROVED', actions: {} })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
@@ -338,11 +357,20 @@ it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('opens %s through the existing dispa
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toEqual([...(taskType === 'LOCAL_TRUCK' ? [expect.objectContaining({ key: 'transport', label: '运输操作' })] : []), expect.objectContaining({ key: 'detail', label: '详情' })])
expect(row.props('actions')).toEqual([...(taskType === 'LOCAL_TRUCK' ? [expect.objectContaining({ key: 'transport', label: '维护车辆' })] : []), expect.objectContaining({ key: 'detail', label: '详情' })])
row.vm.$emit('action', 'detail'); await flushPromises()
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } })
expect(wrapper.text()).toContain('安排到仓取货'); expect(wrapper.text()).toContain('安排本土卡车'); wrapper.unmount()
})
it('opens local truck delivery stage directly on the delivery photo node', async () => {
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType: 'LOCAL_TRUCK', stage: 'DELIVERING', reviewState: 'APPROVED' })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toEqual([expect.objectContaining({ key: 'transport', label: '上传送达照片' }), expect.objectContaining({ key: 'detail', label: '详情' })])
row.vm.$emit('action', 'transport'); await flushPromises()
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props()).toMatchObject({ dispatchNo: 'PC/1', pickupStage: 'DELIVERING', editResources: false })
wrapper.unmount()
})
it('keeps pickup workflow actions but hides route ordering and BOL even when editing is allowed', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType: 'PICKUP', actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
@@ -474,3 +502,13 @@ it.each([
expect(getDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('shows only details after %s delivery completes', async taskType => {
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType, stage: 'DELIVERED', reviewState: 'APPROVED', actions: {} })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions').map((item: { key: string }) => item.key)).toEqual(['detail'])
row.vm.$emit('action', 'transport'); await flushPromises()
expect(wrapper.findComponent({ name: 'TransportExecutionPanel' }).exists()).toBe(false)
wrapper.unmount()
})
@@ -35,6 +35,11 @@ const rejectionError = ref<ApiErrorDetails | null>(null)
const transportOpen = ref(false), transportType = ref<TransportType>('PICKUP')
const transportEditOpen = ref(false), transportEditNo = ref('')
const transportEditTitle = ref('维护取货车辆信息'), transportResourceMode = ref(false)
const transportPanel = ref<InstanceType<typeof TransportExecutionPanel> | null>(null)
const transportCompletionVisible = computed(() => transportPanel.value?.completionVisible ?? false)
const transportCompletionText = computed(() => transportPanel.value?.completionText ?? '完成')
const transportCompletionLoading = computed(() => transportPanel.value?.completionLoading ?? false)
const transportCompletionDisabled = computed(() => transportPanel.value?.completionDisabled ?? true)
const activeTransport = computed(() => rows.value.find(row => row.dispatchNo === transportEditNo.value))
const cancellingPickup = ref('')
function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true }
@@ -125,7 +130,16 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
const review = section.value
return [...(canDispatchAction(row.actions, review, access.can) ? [{ key: review, label: '审核', ariaLabel: dispatchActionLabels[review], priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
}
if (row.taskType === 'LOCAL_TRUCK') return [...(row.status === 'ACTIVE' && (access.can(dispatchActionPermissions.EDIT) || access.can(dispatchActionPermissions.CANCEL) || access.can(dispatchActionPermissions.DEPART)) ? [{ key: 'transport', label: '运输操作', priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
if (row.taskType === 'LOCAL_TRUCK') {
const reviewActions = row.status === 'ACTIVE'
? rowActionOrder.filter(value => ['SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'CANCEL'].includes(value) && (value !== 'CANCEL' || row.stage === 'BEFORE_LOADING') && canDispatchAction(row.actions, value, access.can))
: []
return [
...(row.status === 'ACTIVE' && row.stage !== 'DELIVERED' && (access.can(dispatchActionPermissions.EDIT) || access.can(dispatchActionPermissions.CANCEL) || access.can(dispatchActionPermissions.DEPART)) ? [{ key: 'transport', label: row.stage === 'LOADING' ? '上传取货照片' : row.stage === 'DELIVERING' ? '上传送达照片' : '维护车辆', priority: 700 }] : []),
...reviewActions.map((value, index) => ({ key: value, label: dispatchActionLabels[value], ariaLabel: dispatchActionLabels[value], priority: 600 - index * 10 })),
{ key: 'detail', label: '详情', priority: 500 },
]
}
const available = rowActionOrder.filter(value => (value !== 'SUBMIT_REVIEW' || row.taskType === 'PICKUP') && canDispatchAction(row.actions, value, access.can))
const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
const execution = pickupExecutionLabel(row)
@@ -138,26 +152,27 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
loading: value === 'CANCEL' && cancellingPickup.value === row.dispatchNo,
})), { key: 'detail', label: '详情', priority: 500 }, ...(canDownloadBol(row) ? [{ key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }] : [])]
}
/** 详情只读,照片和资源结束操作从列表进入对应运输面板。 */
/** 已送达仅保留详情,取货和送达照片在对应作业阶段维护。 */
function pickupExecutionLabel(row: DispatchListRow) {
if (row.taskType !== 'PICKUP' || row.status !== 'ACTIVE' || !access.can(dispatchActionPermissions.EDIT)) return ''
if (row.stage === 'DELIVERED') return '结束资源任务'
if (row.stage === 'DELIVERED') return ''
if (row.reviewState !== 'APPROVED') return ''
return row.stage === 'LOADING' ? '上传装货照片' : row.stage === 'DELIVERING' ? '上传到仓照片' : ''
}
function openTransport(row: DispatchListRow, resources = false) {
transportResourceMode.value = resources; transportEditNo.value = row.dispatchNo
transportEditTitle.value = resources ? '维护取货车辆信息' : row.taskType === 'LOCAL_TRUCK' ? '运输操作' : pickupExecutionLabel(row)
transportEditTitle.value = resources ? '维护取货车辆信息' : row.taskType === 'LOCAL_TRUCK' ? (row.stage === 'LOADING' ? '上传取货照片' : row.stage === 'DELIVERING' ? '上传送达照片' : '运输操作') : pickupExecutionLabel(row)
transportEditOpen.value = true
}
function handleAction(value: string, row: DispatchListRow) {
if (value === 'detail') return openDetail(row)
if (showSections.value && section.value !== 'VEHICLE_INFO' && value !== section.value) return
if (value === 'transport') { if (rowActions(row).some(item => item.key === value)) openTransport(row); return }
if (value === 'transport') { if (rowActions(row).some(item => item.key === value)) openTransport(row, row.taskType === 'LOCAL_TRUCK' && row.stage === 'BEFORE_LOADING'); return }
if (value === 'documents') { if (canManageDocuments(row)) { documentsNo.value = row.dispatchNo; documentsOpen.value = true }; return }
if (value === 'bol') return void openBol(row)
if (!rowActions(row).some(item => item.key === value)) return
rejectionError.value = null
if (value === 'route-order' || value === 'SUBMIT_REVIEW' && row.taskType !== 'PICKUP') return
if (value === 'route-order') return
operation.value = undefined
if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return
const requested = value as DispatchAction
@@ -233,7 +248,7 @@ onBeforeUnmount(() => { sequence++ })
</DataTable></div>
</ListPageLayout></AdminPage>
<TransportTaskCreateDialog v-model="transportOpen" :type="transportType" @created="load" />
<AppDialog v-model="transportEditOpen" class="admin-density-dialog" :title="transportEditTitle" width="1000px" :show-confirm="false" cancel-text="关闭"><TransportExecutionPanel v-if="transportEditOpen" :dispatch-no="transportEditNo" :edit-resources="transportResourceMode" :pickup-approved="activeTransport?.reviewState === 'APPROVED'" :pickup-review-state="activeTransport?.reviewState" @saved="load" /></AppDialog>
<AppDialog v-model="transportEditOpen" class="admin-density-dialog" :title="transportEditTitle" width="1000px" :show-confirm="transportCompletionVisible" :confirm-text="transportCompletionText" :confirm-loading="transportCompletionLoading" :confirm-disabled="transportCompletionDisabled" cancel-text="关闭" @confirm="transportPanel?.submitCompletion()"><TransportExecutionPanel v-if="transportEditOpen" ref="transportPanel" :dispatch-no="transportEditNo" :edit-resources="transportResourceMode" :pickup-approved="activeTransport?.reviewState === 'APPROVED'" :pickup-review-state="activeTransport?.reviewState" :pickup-stage="activeTransport?.stage" @saved="load" /></AppDialog>
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
<DispatchDocumentsDialog v-model="documentsOpen" :dispatch-no="documentsNo" @saved="load" />
@@ -442,7 +442,7 @@ onMounted(load)
<tr><td><el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable clearable :loading="feeItemsLoading" :disabled="feeItemsLoading || !!feeItemsError" placeholder="请选择费用项目"><el-option v-for="item in feeItemOptions" :key="item.feeCode" :label="item.feeTypeName" :value="item.feeCode" /></el-select></el-form-item></td><td><el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'PAYABLE'"><td colspan="2"><el-form-item label="供应商" required><el-select v-model="feeForm.supplierId" filterable :loading="suppliersLoading" :disabled="suppliersLoading || !!suppliersError" placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td><td><el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" /></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'RECEIVABLE'"><td colspan="2"><el-form-item label="结算方式"><el-select v-model="feeForm.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'RECEIVABLE'"><td colspan="2"><el-form-item label="结算方式"><el-select v-model="feeForm.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算(账期)" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td><td><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="费用说明" :required="duplicateFees.length > 0"><el-input v-model="feeForm.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item></td></tr>
<tr v-if="duplicateFees.length"><td colspan="2" role="status">此单已有相同费用,确认本次为新增作业后可继续添加。<div v-for="row in duplicateFees" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</div></td></tr>