This commit is contained in:
2026-09-10 15:33:55 +08:00
31 changed files with 770 additions and 167 deletions
@@ -18,11 +18,23 @@ describe('delivery order API', () => {
})
it('loads an encoded detail in its explicit customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { deliveryNo: 'DEL/1' }, skus: [], fees: [], operations: [] } })
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { deliveryNo: 'DEL/1', version: '0' }, skus: [], fees: [], operations: [] } })
await getDeliveryOrder('DEL/1', '1001')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001', page: 1, size: 200 } })
})
it.each(['0', '7', '9007199254740991'])('reads Java Long version %s for status editing', async version => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { version, id: '2097515131537313794' } } })
const detail = await getDeliveryOrder('DEL/1', '1001')
expect(detail.order.version).toBe(Number(version))
expect(detail.order.id).toBe('2097515131537313794')
})
it.each([null, undefined, '', ' ', '-1', '1.5', '9007199254740992', -1])('rejects invalid version %s', async version => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { version } } })
await expect(getDeliveryOrder('DEL/1', '1001')).rejects.toThrow('派送单缺少有效版本')
})
it('loads return options and marks a return in the explicit customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [{ inboundOrderId: '9', inboundCode: 'IN-1' }] })
await listDeliveryReturnInboundOptions('DEL/1', '1001')
@@ -97,7 +97,15 @@ export async function getDeliveryOrder(deliveryNo: string, customerId: string) {
const response = await overseasHttp.get<ApiEnvelope<DeliveryOrderDetail>, ApiEnvelope<DeliveryOrderDetail>>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}`, { params: { customerId, page: 1, size: 200 } },
)
return unwrapApiResponse(response)
const detail = unwrapApiResponse(response)
// Java Long values are serialized as decimal strings, including the version.
const rawVersion: unknown = detail.order.version
const version = typeof rawVersion === 'string' && /^(0|[1-9]\d*)$/.test(rawVersion)
? Number(rawVersion) : rawVersion
if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 0) {
throw new Error('派送单缺少有效版本,请刷新后重试')
}
return { ...detail, order: { ...detail.order, version } }
}
export async function listDeliveryReturnInboundOptions(deliveryNo: string, customerId: string) {
@@ -81,7 +81,7 @@ describe('dispatch overseas API', () => {
it('transfers to delivery only by dispatchNo without customerId', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', deliveryNo: 'DEL-1' } })
const payload = { idempotencyKey: 'transfer-1', plannedDeliveryAt: '2026-09-01T10:00:00', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', fees: [{ feeType: 'DELIVERY_FEE', currency: 'USD', amount: 10 }] }
const payload = { idempotencyKey: 'transfer-1', plannedDeliveryAt: '2026-09-01T10:00:00', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费用', direction: 'RECEIVABLE' as const, currency: 'USD' as const, amount: 10 }] }
await transferDispatchToDelivery('PC/1', payload)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', payload)
})
@@ -214,7 +214,11 @@ export interface TransferDeliveryPayload {
appointmentAccount?: string
sellerName?: string
remark?: string
fees: Array<{ feeType: string; currency: string; amount: number; remark?: string }>
fees: Array<{ feeType: string; feeName: string; direction: 'RECEIVABLE' | 'PAYABLE'; currency: 'CNY' | 'USD'; amount: number
supplierId?: string; supplierName?: string; supplierCategory?: string; settlementMode?: 'ON_ACCOUNT' | 'DIRECT_PAID'
paidAt?: string; paymentMethod?: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'; paymentReferenceNo?: string
paymentVoucherFileIds?: string[]; chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number
periodStart?: string; periodEnd?: string; remark?: string }>
palletCartonMarks?: Record<string, string[]>
}
export async function transferDispatchToDelivery(dispatchNo: string, payload: TransferDeliveryPayload) {
@@ -125,17 +125,14 @@ export interface LastMileStatusHistoryDto {
}
export interface LastMileFeeDto {
id: string
customerId: string
orderId: string
feeItem: string
currency: string
amount: number
status: string
supplierDescription?: string | null
createdBy: string
createdAt: string
updatedAt: string
id: string; sourceFeeId: string; sourceVersion: number; customerId: string; orderId?: string | null
feeItemCode: string; feeItemName: string; direction: 'RECEIVABLE' | 'PAYABLE'
currency: 'CNY'; amount: number; originalCurrency: 'CNY' | 'USD'; originalAmount: number
exchangeRate: number; exchangeRateMonth: string; reviewStatus: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED'
publicationStatus: string; sourceStatus: string; supplierId?: string | null; supplierName?: string | null
settlementMode?: 'ON_ACCOUNT' | 'DIRECT_PAID' | null; businessDate: string
chargeableQuantity?: number | null; quantityUnit?: string | null; unitPrice?: number | null
periodStart?: string | null; periodEnd?: string | null; createdAt: string; updatedAt: string
}
export interface LastMileOperationDto {
@@ -147,11 +144,11 @@ export interface LastMileOperationDto {
}
export interface CreateLastMileFeePayload {
feeItem: string
currency: string
amount: number
status: string
supplierDescription?: string
feeItemCode: string; feeItemName: string; direction: 'RECEIVABLE' | 'PAYABLE'; currency: 'CNY' | 'USD'
amount: number; businessDate: string; supplierId?: string; supplierName?: string; supplierCategory?: string
settlementMode?: 'ON_ACCOUNT' | 'DIRECT_PAID'; paidAt?: string; paymentMethod?: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'
paymentReferenceNo?: string; paymentVoucherFileIds?: string[]; chargeableQuantity?: number
quantityUnit?: string; unitPrice?: number; periodStart?: string; periodEnd?: string
}
export interface TransferDestinationPayload {
@@ -378,6 +375,7 @@ export async function downloadLastMileOrders(query: Partial<LastMileOrderQuery>)
export async function createLastMileOrderFee(orderId: string, payload: CreateLastMileFeePayload) {
const response = await overseasHttp.post<ApiEnvelope<LastMileFeeDto>, ApiEnvelope<LastMileFeeDto>>(
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/fees`, payload,
{ headers: { 'Idempotency-Key': `last-mile-fee-${orderId}-${Date.now()}-${Math.random().toString(36).slice(2)}` } },
)
return unwrapApiResponse(response)
}
@@ -201,7 +201,7 @@ async function submitTransfer() {
if (needsLoadingFee.value && transferForm.loadingFee == null) { ElMessage.warning('当前装车类型需要填写装车费'); return }
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || transferForm.appointmentFee == null)) { ElMessage.warning('买约时请填写卖家姓名和买约费用'); return }
const feeInputs = [['DELIVERY_FEE', transferForm.deliveryFee, '派送费用'], ['LOADING_FEE', needsLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'], ['APPOINTMENT_FEE', isBuyAppointment.value ? transferForm.appointmentFee : undefined, '买约费用']] as const
const fees = feeInputs.filter(([, amount]) => amount != null).map(([feeType, amount, remark]) => ({ feeType, currency: 'USD', amount: Number(amount), remark }))
const fees = feeInputs.filter(([, amount]) => amount != null).map(([feeType, amount, feeName]) => ({ feeType, feeName, direction: 'RECEIVABLE' as const, currency: 'USD' as const, amount: Number(amount), remark: feeName }))
let palletCartonMarks: Record<string, string[]> | undefined
if (transferForm.cartonMarksJson.trim()) {
try { const parsed = JSON.parse(transferForm.cartonMarksJson); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(); palletCartonMarks = Object.fromEntries(Object.entries(parsed).map(([pallet, marks]) => [pallet, Array.isArray(marks) ? marks.map((mark) => String(mark).trim()).filter(Boolean) : []])) } catch { ElMessage.warning('托盘箱唛分配格式不正确,请使用 JSON:{"托盘号":["箱唛1","箱唛2"]}'); return }
@@ -4,6 +4,7 @@ import { Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -33,8 +34,9 @@ const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const activeTab = ref('basic')
const feeDialogOpen = ref(false)
const feeForm = reactive({ item: '', currency: 'USD', amount: 0, status: 'PENDING_REVIEW', description: '' })
const feeItemOptions = ['仓储费', '入库操作费', '出库操作费', '换标费', '托盘费', '派送费', '转仓费', '异常处理费', '其他']
const supplierOptions = ref<Supplier[]>([])
const feeForm = reactive({ itemCode: '', itemName: '', direction: 'RECEIVABLE' as 'RECEIVABLE' | 'PAYABLE', currency: 'USD' as 'CNY' | 'USD', amount: 0, businessDate: new Date().toISOString().slice(0, 10), supplierId: '', quantity: undefined as number | undefined, quantityUnit: '', periodStart: '', periodEnd: '' })
const feeItemOptions = [{ code: 'STORAGE_FEE', name: '仓租' }, { code: 'INBOUND_OPERATION', name: '入库操作费' }, { code: 'OUTBOUND_OPERATION', name: '出库操作费' }, { code: 'RELABEL_FEE', name: '换标费' }, { code: 'REBOX_FEE', name: '换箱费' }, { code: 'PALLET_FEE', name: '托盘费' }, { code: 'DELIVERY_FEE', name: '派送费' }, { code: 'TRANSFER_FEE', name: '转仓费' }, { code: 'EXCEPTION_FEE', name: '异常处理费' }, { code: 'OTHER', name: '其他' }]
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
@@ -93,8 +95,8 @@ const cargo = computed<CargoRow[]>(() => (detail.value?.cargo || []).map((item)
cartons: Number(item.packageCount || 0), quantity: Number(item.productQuantity || 0), weight: Number(item.grossWeightKg || 0), volume: Number(item.volumeCbm || 0),
})))
const fees = computed<FeeRow[]>(() => (detail.value?.fees || []).map((item) => ({
id: String(item.id), item: item.feeItem, currency: item.currency, amount: `${item.currency} ${Number(item.amount).toFixed(2)}`,
status: feeStatusLabel(item.status), description: text(item.supplierDescription),
id: String(item.id), item: item.feeItemName, currency: item.originalCurrency, amount: `${item.originalCurrency} ${Number(item.originalAmount).toFixed(2)}`,
status: feeStatusLabel(item.reviewStatus === 'PENDING' ? 'PENDING_REVIEW' : item.reviewStatus), description: text(item.supplierName || (item.chargeableQuantity == null ? null : `${item.chargeableQuantity} ${item.quantityUnit || ''}`)),
})))
const logs = computed<LogRow[]>(() => {
const statusRows = (detail.value?.statusHistory || []).map((item) => ({
@@ -134,16 +136,22 @@ async function load() {
catch (error) { loadError.value = error instanceof Error && error.message === '当前环境未签发海外访问令牌' ? { message: error.message } : getApiErrorDetails(error, '尾程订单详情加载失败,请重试') }
finally { loading.value = false }
}
function openFeeDialog() {
Object.assign(feeForm, { item: '', currency: 'USD', amount: 0, status: 'PENDING_REVIEW', description: '' })
async function openFeeDialog() {
Object.assign(feeForm, { itemCode: '', itemName: '', direction: 'RECEIVABLE', currency: 'USD', amount: 0, businessDate: new Date().toISOString().slice(0, 10), supplierId: '', quantity: undefined, quantityUnit: '', periodStart: '', periodEnd: '' })
mutationError.value = null; feeDialogOpen.value = true
if (access.can('platform:supplier:list') && supplierOptions.value.length === 0) {
try { supplierOptions.value = (await listSuppliers({ page: 1, size: 200, status: 'ACTIVE' })).records.filter((item) => item.status === 'ACTIVE') }
catch (error) { mutationError.value = getApiErrorDetails(error, '供应商列表加载失败') }
}
}
async function saveFee() {
if (!feeForm.item.trim() || feeForm.amount <= 0) { ElMessage.warning('请填写费用项目和大于 0 的金额'); return }
if (!feeForm.itemCode || !feeForm.itemName.trim() || feeForm.amount <= 0 || !feeForm.businessDate) { ElMessage.warning('请填写费用项目、业务日期和大于 0 的金额'); return }
const supplier = supplierOptions.value.find((item) => String(item.id) === feeForm.supplierId)
if (feeForm.direction === 'PAYABLE' && !supplier) { ElMessage.warning('应付费用必须选择供应商'); return }
saving.value = true; mutationError.value = null
try {
await createLastMileOrderFee(orderId.value, { feeItem: feeForm.item.trim(), currency: feeForm.currency, amount: feeForm.amount, status: feeForm.status, supplierDescription: feeForm.description.trim() || undefined })
feeDialogOpen.value = false; ElMessage.success('费用已新增'); await load(); activeTab.value = 'fees'
await createLastMileOrderFee(orderId.value, { feeItemCode: feeForm.itemCode, feeItemName: feeForm.itemName.trim(), direction: feeForm.direction, currency: feeForm.currency, amount: feeForm.amount, businessDate: feeForm.businessDate, supplierId: supplier ? String(supplier.id) : undefined, supplierName: supplier?.name, supplierCategory: supplier?.category, settlementMode: supplier ? 'ON_ACCOUNT' : undefined, chargeableQuantity: feeForm.quantity, quantityUnit: feeForm.quantity == null ? undefined : feeForm.quantityUnit.trim(), unitPrice: feeForm.quantity ? feeForm.amount / feeForm.quantity : undefined, periodStart: feeForm.periodStart || undefined, periodEnd: feeForm.periodEnd || undefined })
feeDialogOpen.value = false; ElMessage.success('费用已提交财务审核'); await load(); activeTab.value = 'fees'
} catch (error) { mutationError.value = getApiErrorDetails(error, '费用新增失败,请重试') }
finally { saving.value = false }
}
@@ -175,11 +183,14 @@ onMounted(load)
</section>
<AppDialog v-model="feeDialogOpen" title="新增费用" width="min(720px, calc(100vw - 28px))" confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
<el-form class="fee-form" label-position="top"><div class="fee-form-grid">
<el-form-item label="费用项目" required><el-select v-model="feeForm.item" filterable allow-create clearable default-first-option placeholder="选择或输入费用项目"><el-option v-for="item in feeItemOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item>
<el-form-item label="供应商/说明"><el-input v-model="feeForm.description" maxlength="500" placeholder="请输入供应商或费用说明" /></el-form-item>
<el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option v-for="currency in ['USD', 'CNY', 'EUR', 'GBP', 'CAD', 'MXN']" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item>
<el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable allow-create clearable default-first-option placeholder="请选择或输入费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || String(feeForm.itemCode).trim()"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></el-select></el-form-item>
<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>
<el-form-item v-if="feeForm.direction === 'PAYABLE'" label="供应商" required><el-select v-model="feeForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
<el-form-item label="业务日期" required><el-date-picker v-model="feeForm.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<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>
<el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" placeholder="请输入费用金额" /></el-form-item>
<el-form-item class="fee-form-full" label="状态" required><el-select v-model="feeForm.status" placeholder="请选择费用状态"><el-option label="待审核" value="PENDING_REVIEW" /><el-option label="审核通过" value="APPROVED" /><el-option label="已驳回" value="REJECTED" /></el-select></el-form-item>
<el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入计费数量" /></el-form-item><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item>
<el-form-item label="计费开始日期"><el-date-picker v-model="feeForm.periodStart" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item><el-form-item label="计费结束日期"><el-date-picker v-model="feeForm.periodEnd" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item>
</div></el-form>
</AppDialog>
</OverseasDetailShell>
@@ -29,13 +29,13 @@ describe('OutboundsView permissions and navigation', () => {
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('详情')
expect(wrapper.text()).not.toContain('一件代发出库')
expect(wrapper.text()).not.toContain('新建出库单')
expect(wrapper.text()).not.toContain('取消')
expect(OutboundsView.__file).toContain('OutboundsView.vue')
wrapper.unmount()
})
it('shows one-click creation and cancellation only with their exact permissions', async () => {
it('shows outbound creation and cancellation only with their exact permissions', async () => {
canPermission.mockImplementation((code: string) => ['platform:overseas-warehouse:outbound:create', 'platform:overseas-warehouse:outbound:cancel'].includes(code))
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -84,9 +84,12 @@ describe('overseas warehouse first-batch prototype contract', () => {
const value = source(fileName)
expect(value).toContain('<OverseasReadOnlyDetail')
expect(value).toContain('@back="back"')
expect(value).not.toContain('AppDialog')
expect(value).not.toContain('AppDrawer')
if (fileName !== 'RelabelOrderDetailView.vue') expect(value).not.toContain('AppDialog')
}
const relabel = source('RelabelOrderDetailView.vue')
expect(relabel).toContain('<AppDialog v-if="previewUrl"')
expect(relabel).toContain('<iframe :src="previewUrl"')
})
it('keeps second-batch forms non-submitting until overseas write contracts exist', () => {