This commit is contained in:
2026-11-03 11:43:50 +08:00
parent f8408b8076
commit 1c2dd8797a
34 changed files with 354 additions and 84 deletions
@@ -1,5 +1,16 @@
# 海外业务原型核对矩阵
## 2026-11-02 海外自动仓租计算过程
依据:本轮用户确认“退货余一箱仍计一托、沿用现有阶梯”,以及“海外自动计算的仓租展示费用计算过程”。沿用既有费用详情、审核和调价弹框,按 Admin 高密度规范的正文分区与表格规则展示,不改变入口、权限及资金动作。
| 直接消费者 | 操作与现有承载 | 本次实现与核对 | 验收 |
|---|---|---|---|
| OverseasFeesView | 海外费用管理的费用详情弹框 | StorageRentFeeDetails 展示冻结快照的计租基数、原库龄、已计费日、逐档天数×基数×单价、小计及汇率换算 | 定向回归通过;待人工 1920×1080、100% 截图确认 |
| BusinessOrderFees | 退货、废弃、换标等业务单费用详情弹框 | 复用相同计算过程;旧比例费用明确保留原计费口径,缺少依据不猜算 | 定向回归通过;待人工截图确认 |
| StorageRentFeeActions | 业务单仓租审核、调价弹框 | 原计算过程保持可见;调整金额不改写原快照 | 定向回归通过;待人工截图确认 |
| StorageRentPanel | 仓租库存的原批次详情弹框 | 出库计租基数优先采用费用快照,新整托费用不再展示件数分摊的零点几托 | 定向回归通过;待人工截图确认 |
采购样品履约的当前操作与页面承载以[采购订单样品履约流程变更需求第 10.6 节](../../../../../docs/海外仓业务/采购订单样品履约流程变更需求.md#106-平台页面实施核对矩阵)为准。下表采购旧五分区和通用编辑仅保留原型定位;当前详情使用履约聚合数据,独立编辑只维护内部备注,节点写入使用各自权限和命令。
本土散板列表、本土卡车新增/编辑共用`../overseas-master-data/index.ts`公开的`TransportRouteDialog`。字段来源、原型证据和缺值处理唯一见[运输路线地图](../overseas-master-data/PROTOTYPE_MATRIX.md#运输路线地图)。按已有起终点规划,不写订单、不使用固定演示、不采集GPS。
@@ -2,8 +2,15 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface FeePage<T> { records: T[]; page: number; size: number; total: number; pages: number }
export type OverseasFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export interface StorageRentCalculation {
sourceBusinessNo: string | null; batchNo: string | null; ruleCode: 'RETURN_PALLET' | 'RETURN_SHARE' | 'TRANSIT'
billingQuantity: number; billingUnit: 'PALLET' | 'CBM'; storageDays: number; previouslyChargedDays: number
tiers: Array<{ startDay: number; endDay: number; days: number; unitPrice: number; amount: number }>
calculatedAmount: number
}
export interface OverseasFee {
storageRent?: { outboundNo: string; outboundAt: string; calculatedAmount: number; chargedAmount: number | null;
calculations?: StorageRentCalculation[]
lines: Array<{ lotId: string; inboundAt: string; quantity: number; days: number; calculatedAmount: number;
sourceBusinessType?: string | null; sourceBusinessNo?: string | null; batchNo?: string | null }> } | null
id: string; sourceFeeId: string; sourceVersion: number; customerId: string; customerName: string
@@ -15,6 +15,7 @@ export interface StorageRentLot {
settledAmount?: number; chargeCount?: number; settledChargeCount?: number
}
export interface StorageRentCharge {
billingQuantity?: number | null; billingUnit?: string | null
id: string; outboundNo: string; businessNo: string; quantity: number; storageDays: number
originalAmount: number; currency: string; settlementAmount: number; currencyRate: number; status: string
createdAt: string; outboundAt: string
@@ -188,17 +188,17 @@ describe('单据费用操作', () => {
await flushPromises(); await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled()
})
it('blocks automatic storage rent from manual addition', async () => {
it('allows manual storage rent using the normal fee submission', async () => {
vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'FEE_TYPE_077', feeTypeName: '仓储费', defaultPrice: null }])
const wrapper = await mountPage()
await click(wrapper, '新增费用')
const dialog = activeDialog(wrapper)
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
expect(select.findAllComponents({ name: 'ElOption' })).toHaveLength(0)
expect(select.findAllComponents({ name: 'ElOption' })).toHaveLength(1)
select.vm.$emit('update:modelValue', 'FEE_TYPE_077')
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10)
await flushPromises(); await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled()
expect(api.createOverseasFee).toHaveBeenCalledWith(expect.objectContaining({ feeItemCode: 'FEE_TYPE_077', amount: 10 }), expect.any(String))
})
it('includes verified related orders but rejects another customer and keeps adjustment on the original order', async () => {
const related = { ...business, businessId: 'return-1', businessType: 'RETURN_ORDER' }
@@ -32,8 +32,7 @@ let optionSequence = 0
let savedFingerprint = '', requestKey = ''
let openedBusinessKey = ''
const businessKey = () => props.business ? `${props.business.businessType}:${props.business.businessId}:${props.business.customerId}` : ''
const automaticRentCode = 'FEE_TYPE_077'
const manualItems = computed(() => props.items.filter(item => item.feeCode !== automaticRentCode))
const manualItems = computed(() => props.items)
const hasExistingItem = computed(() => !!props.fee && manualItems.value.some(item => item.feeCode === props.fee?.feeItemCode))
async function loadOptions(keyword = '') {
if (props.business) return
@@ -90,7 +89,6 @@ const sourceTypes: Record<string, string> = {
const selectableTypes = Object.keys(sourceTypes)
async function save() {
if (!access.can(feePermissions.edit) || saving.value || optionsLoading.value || !await formRef.value?.validate().catch(() => false)) return
if (form.feeItemCode === automaticRentCode) { ElMessage.error('仓租自动计算,请勿手动添加'); return }
const order = selected.value
if (!order) { ElMessage.error('请重新选择业务订单'); return }
if (existingLoading.value || existingError.value) { ElMessage.warning(existingError.value || '正在核对已有费用'); return }
@@ -132,7 +130,7 @@ async function save() {
<div class="fee-editor-grid">
<el-form-item v-if="!business" label="业务类型" prop="businessType"><el-select v-model="form.businessType" :disabled="!!fee" @change="changeBusinessType"><el-option v-for="type in selectableTypes" :key="type" :value="type" :label="businessLabels[type]" /></el-select></el-form-item>
<el-form-item v-if="!business" label="业务订单" prop="businessId"><el-select v-model="form.businessId" filterable remote :remote-method="loadOptions" :loading="optionsLoading" :disabled="!!fee" placeholder="搜索订单号"><el-option v-for="row in options" :key="row.businessId" :value="row.businessId" :label="`${row.businessNo} · ${row.customerName}`" /></el-select></el-form-item>
<el-form-item label="费用项目" prop="feeItemCode"><el-select v-model="form.feeItemCode" filterable><el-option v-if="fee && fee.feeItemCode !== automaticRentCode && !hasExistingItem" :value="fee.feeItemCode" :label="fee.feeItemName" /><el-option v-for="item in manualItems" :key="item.feeCode" :value="item.feeCode" :label="item.feeTypeName" /></el-select></el-form-item>
<el-form-item label="费用项目" prop="feeItemCode"><el-select v-model="form.feeItemCode" filterable><el-option v-if="fee && !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>
@@ -0,0 +1,52 @@
import { createRequire } from 'node:module'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { OverseasFee, StorageRentCalculation } from '../api/overseasFees'
import StorageRentFeeDetails from './StorageRentFeeDetails.vue'
const { default: ElementPlus } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
enableAutoUnmount(afterEach)
beforeEach(() => vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }))
afterEach(() => vi.unstubAllGlobals())
const calculation: StorageRentCalculation = {
sourceBusinessNo: 'TH202611020001', batchNo: 'RB1', ruleCode: 'RETURN_PALLET', billingQuantity: 1, billingUnit: 'PALLET',
storageDays: 40, previouslyChargedDays: 10, calculatedAmount: 50,
tiers: [{ startDay: 11, endDay: 30, days: 20, unitPrice: 1.5, amount: 30 }, { startDay: 31, endDay: 40, days: 10, unitPrice: 2, amount: 20 }],
}
function fee(calculations?: StorageRentCalculation[]) {
return { originalCurrency: 'USD', currency: 'CNY', originalAmount: 50, amount: 500, exchangeRate: 10,
storageRent: { outboundNo: 'OUT1', outboundAt: '2026-11-02T12:00:00Z', calculatedAmount: 50, chargedAmount: null,
lines: [{ lotId: '1', inboundAt: '2026-09-24T12:00:00Z', quantity: 1, days: 40, calculatedAmount: 50 }], calculations },
} as OverseasFee
}
it('explains whole pallet tiers and skips previously charged days in every fee detail consumer', async () => {
const wrapper = mount(StorageRentFeeDetails, { props: { fee: fee([calculation]) }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('余一箱仍计一托')
expect(wrapper.text()).toContain('此前已计费10 天')
expect(wrapper.text()).toContain('20 天 × 1 托 × USD 1.5 = USD 30')
expect(wrapper.text()).toContain('10 天 × 1 托 × USD 2 = USD 20')
expect(wrapper.text()).toContain('USD 50.00 × 10 = CNY 500.00')
expect(wrapper.text()).toContain('本批次计算金额:USD 50')
})
it('shows transit volume and free tier calculations with the saved currency', async () => {
const transit: StorageRentCalculation = { ...calculation, ruleCode: 'TRANSIT', billingUnit: 'CBM', billingQuantity: 0.8,
storageDays: 10, previouslyChargedDays: 0, calculatedAmount: 1.92,
tiers: [{ startDay: 1, endDay: 7, days: 7, unitPrice: 0, amount: 0 }, { startDay: 8, endDay: 10, days: 3, unitPrice: 0.8, amount: 1.92 }] }
const wrapper = mount(StorageRentFeeDetails, { props: { fee: fee([transit]) }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('7 天 × 0.8 CBM × USD 0 = USD 0')
expect(wrapper.text()).toContain('3 天 × 0.8 CBM × USD 0.8 = USD 1.92')
})
it('distinguishes historical fractional pricing from new whole pallet pricing', async () => {
const historical: StorageRentCalculation = { ...calculation, ruleCode: 'RETURN_SHARE', billingQuantity: 0.5625,
storageDays: 1, previouslyChargedDays: 0, calculatedAmount: 0.84375,
tiers: [{ startDay: 1, endDay: 1, days: 1, unitPrice: 1.5, amount: 0.84375 }] }
const wrapper = mount(StorageRentFeeDetails, { props: { fee: fee([historical]) }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('历史费用:按原托份额计费')
expect(wrapper.text()).toContain('1 天 × 0.5625 托 × USD 1.5 = USD 0.84375')
await wrapper.setProps({ fee: fee([]) })
expect(wrapper.text()).toContain('未保存完整计算依据')
expect(wrapper.text()).not.toContain('1 天 × 0.5625')
})
@@ -4,6 +4,8 @@ import type { OverseasFee } from '../api/overseasFees'
import { feeMoney } from '../overseasFeePresentation'
import { storageRentSourceType, storageRentTime } from '../storageRentPresentation'
defineProps<{ fee: OverseasFee }>()
const number = (value: number) => new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 12 }).format(Number(value))
const unit = (value: string) => value === 'PALLET' ? '托' : 'CBM'
</script>
<template>
<section v-if="fee.storageRent" aria-label="仓租计费明细">
@@ -13,6 +15,7 @@ defineProps<{ fee: OverseasFee }>()
<el-descriptions-item label="原计算金额">{{ feeMoney(fee.storageRent.calculatedAmount, fee.originalCurrency) }}</el-descriptions-item>
<el-descriptions-item label="应扣金额">{{ feeMoney(fee.amount, fee.currency) }}</el-descriptions-item>
<el-descriptions-item label="实扣金额">{{ feeMoney(fee.storageRent.chargedAmount, fee.currency) }}</el-descriptions-item>
<el-descriptions-item v-if="fee.exchangeRate != null" label="结算换算">{{ feeMoney(fee.originalAmount, fee.originalCurrency) }} × {{ number(fee.exchangeRate) }} = {{ feeMoney(fee.amount, fee.currency) }}</el-descriptions-item>
</el-descriptions>
<el-table :data="fee.storageRent.lines" border empty-text="暂无计租批次">
<el-table-column label="来源类型" min-width="130"><template #default="{ row }">{{ storageRentSourceType(row.sourceBusinessType) }}</template></el-table-column>
@@ -20,8 +23,29 @@ defineProps<{ fee: OverseasFee }>()
<el-table-column label="入库批次" min-width="160"><template #default="{ row }">{{ row.batchNo || '—' }}</template></el-table-column>
<el-table-column label="入库时间" min-width="170"><template #default="{ row }">{{ storageRentTime(row.inboundAt) }}</template></el-table-column>
<el-table-column prop="quantity" label="本次出库数量" width="130" />
<el-table-column prop="days" label="计租天数" width="100" />
<el-table-column prop="days" label="库龄天数" width="100" />
<el-table-column label="原计算仓租" min-width="130"><template #default="{ row }">{{ feeMoney(row.calculatedAmount, fee.originalCurrency) }}</template></el-table-column>
</el-table>
<h4>仓租计算过程</h4>
<template v-if="fee.storageRent.calculations?.length">
<div v-for="(calculation, index) in fee.storageRent.calculations" :key="index">
<el-descriptions :column="2" border class="density-descriptions overseas-fee-descriptions">
<el-descriptions-item label="来源单号">{{ calculation.sourceBusinessNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="入库批次">{{ calculation.batchNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="计费口径">{{ calculation.ruleCode === 'RETURN_PALLET' ? '按原托整托计费,余一箱仍计一托' : calculation.ruleCode === 'RETURN_SHARE' ? '历史费用:按原托份额计费' : '按本次出库体积计费' }}</el-descriptions-item>
<el-descriptions-item label="计租数量">{{ number(calculation.billingQuantity) }} {{ unit(calculation.billingUnit) }}</el-descriptions-item>
<el-descriptions-item label="累计库龄">{{ calculation.storageDays }} 天</el-descriptions-item>
<el-descriptions-item label="此前已计费">{{ calculation.previouslyChargedDays }} 天</el-descriptions-item>
</el-descriptions>
<el-table :data="calculation.tiers" border empty-text="本计租日已计费,本次不重复收费" aria-label="仓租阶梯计算">
<el-table-column label="计费区间" min-width="130"><template #default="{ row }">第 {{ row.startDay }}–{{ row.endDay }} 天</template></el-table-column>
<el-table-column label="单价" min-width="160"><template #default="{ row }">{{ fee.originalCurrency }} {{ number(row.unitPrice) }}/{{ unit(calculation.billingUnit) }}/天</template></el-table-column>
<el-table-column label="计算过程" min-width="300"><template #default="{ row }">{{ row.days }} 天 × {{ number(calculation.billingQuantity) }} {{ unit(calculation.billingUnit) }} × {{ fee.originalCurrency }} {{ number(row.unitPrice) }} = {{ fee.originalCurrency }} {{ number(row.amount) }}</template></el-table-column>
</el-table>
<p>本批次计算金额:{{ fee.originalCurrency }} {{ number(calculation.calculatedAmount) }}</p>
</div>
<p>各阶梯按原始精度累加,仓租合计四舍五入至 2 位小数;计价采用生成费用时保存的报价。</p>
</template>
<p v-else>该笔费用未保存完整计算依据,暂无法展示计算过程。</p>
</section>
</template>
@@ -28,6 +28,19 @@ beforeEach(() => {
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('仓租列表服务端契约', () => {
it('uses the whole pallet billing basis from the charge snapshot when only one box leaves', async () => {
vi.mocked(api.getStorageRentLot).mockResolvedValue({ lot: { ...lot, businessType: 'RETURN_ORDER', billingUnit: 'PALLET', originalQuantity: 16 },
charges: [{ id: '3', outboundNo: 'OUT1', businessNo: 'CD1', quantity: 1, storageDays: 1, originalAmount: 1.5,
currency: 'USD', settlementAmount: 15, currencyRate: 10, status: 'PENDING_REVIEW', createdAt: '', outboundAt: '', billingQuantity: 1, billingUnit: 'PALLET' }] })
const wrapper = mount(StorageRentPanel, { attachTo: document.body, props: { customers: [] }, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text().trim() === '详情')!.trigger('click')
await flushPromises()
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('本次计租基数')
expect(dialog.textContent).toContain('1 托')
expect(dialog.textContent).not.toContain('0.0625 托')
})
it('shows storage age and remaining quantity and traces each half pallet without financial actions', async () => {
const returned = { ...lot, businessType: 'RETURN_ORDER', businessNo: 'TH1', quantityUnit: 'PIECE', originalQuantity: 6, remainingQuantity: 2, billingQuantity: 1.5, billingUnit: 'PALLET', status: 'IN_STOCK', generatedAmounts: [{ currency: 'USD', amount: 1.5 }], settledAmount: 10, chargeCount: 2, settledChargeCount: 2 }
vi.mocked(api.listStorageRentLots).mockResolvedValue({ records: [returned], total: 1, page: 1, size: 20, pages: 1 })
@@ -39,7 +39,7 @@ const chargeColumns = computed<DataTableColumn[]>(() => [
{ prop: 'outboundNo', label: '出库批次', minWidth: 170 },
{ prop: 'businessNo', label: '出库业务单号', minWidth: 170 },
{ prop: 'quantity', label: '出库数量', width: 105, align: 'right', formatter: (row: { quantity: number }) => `${row.quantity} ${unitLabels[detail.value?.lot.quantityUnit || ''] || ''}`.trim() }, { prop: 'storageDays', label: '计租天数', width: 100, align: 'right' },
{ key: 'billingShare', label: '本次计租份额', width: 120, formatter: (row: StorageRentCharge) => detail.value ? storageRentBillingShare(row.quantity, detail.value.lot) : '—' },
{ key: 'billingShare', label: '本次计租基数', width: 120, formatter: (row: StorageRentCharge) => detail.value ? storageRentBillingShare(row.quantity, detail.value.lot, row) : '—' },
{ prop: 'outboundAt', label: '出库时间', minWidth: 165, formatter: (row: { outboundAt: string }) => storageRentTime(row.outboundAt) },
])
let sequence = 0
@@ -3,7 +3,6 @@ import '@/styles/adminWorkbench.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -107,7 +106,7 @@ async function submit() {
<template v-if="active">
<p v-if="active !== 'review' && active !== 'arrange'">{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p>
<el-alert v-if="validationError" :title="validationError" type="warning" :closable="false" role="alert" />
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
<el-alert v-if="error" :title="error.message" type="error" :closable="false" role="alert" />
<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="" 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>
@@ -407,7 +407,7 @@ describe('overseas business mounted pages', () => {
? ['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', estimatedPalletCount: domain === 'truck' ? 2 : 0, origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: domain === 'truck' ? 100 : 0, currency: 'CNY' })
Object.assign(model, { vehicleType: 'PALLET_15', estimatedPalletCount: domain === 'truck' ? 2 : 0, origin: ' LA ', destination: ' ONT ', originAddress: ' Pickup street ', destinationAddress: ' Delivery street ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: domain === 'truck' ? 100 : 0, currency: 'CNY' })
const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')!
await submit.trigger('click'); await flushPromises()
expect(createUsTruckOrder).not.toHaveBeenCalled(); expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
@@ -438,7 +438,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 model = wrapper.findComponent({ name: 'ElForm' }).props('model'); Object.assign(model, { estimatedPalletCount: 2, budgetAmount: 100 }); 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('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 model = wrapper.findComponent({ name: 'ElForm' }).props('model'); Object.assign(model, { estimatedPalletCount: 2, budgetAmount: 100, originAddress: 'Pickup street', destinationAddress: 'Delivery street' }); 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 () => {
@@ -83,7 +83,7 @@ describe('truck review and fulfillment', () => {
expect(header.find('.status-badge').text()).toBe('待审核')
expect(wrapper.findAll('.status-badge')).toHaveLength(1)
expect(header.find('.truck-order-actions').text()).toContain('审核')
expect(header.find('.truck-order-actions').text()).toContain('取消订单')
expect(header.find('.truck-order-actions').text()).not.toContain('取消订单')
await click(wrapper, '刷新订单')
expect(getUsTruckOrder).toHaveBeenCalledTimes(2)
await click(wrapper, '返回列表')
@@ -126,6 +126,9 @@ describe('truck review and fulfillment', () => {
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车')
formModel(wrapper).remark = '核对完成'; await confirm(wrapper)
expect(wrapper.text()).toContain('订单版本冲突')
expect(wrapper.text()).not.toContain('TRUCK-409')
expect(wrapper.text()).not.toContain('请求 ID')
expect(wrapper.text()).not.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(TruckOrderActions).findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
@@ -160,11 +163,10 @@ describe('truck review and fulfillment', () => {
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 () => {
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '取消订单'); await confirm(wrapper)
it('does not offer platform cancellation of a customer truck order', async () => {
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.text()).not.toContain('取消订单')
expect(executeUsTruckCommand).not.toHaveBeenCalled()
formModel(wrapper).remark = '客户取消需求'; vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'CANCELLED', version: 2 })); await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'cancel', payload: expect.objectContaining({ status: 'CANCELLED', remark: '客户取消需求' }) }))
})
it('keeps list-only users read-only and guards direct attempts to emit submit', async () => {
@@ -9,6 +9,7 @@ it('distinguishes missing charges, genuine free rent and incomplete settlement',
expect(storageRentSummaryStatus({ ...lot, settledAmount: 0 })).toBe('免租 · 已结算')
expect(storageRentSummaryStatus({ ...lot, chargeCount: 0 })).toBe('仓租记录待核对')
expect(storageRentBillingShare(2, lot)).toBe('0.5 托')
expect(storageRentBillingShare(1, lot, { billingQuantity: 1, billingUnit: 'PALLET' })).toBe('1 托')
})
describe('仓租原币汇总显示', () => {
@@ -1,5 +1,5 @@
import { feeMoney } from './overseasFeePresentation'
import type { StorageRentLot } from './api/storageRent'
import type { StorageRentLot, StorageRentCharge } from './api/storageRent'
export function storageRentSourceType(type?: string | null) {
const labels: Record<string, string> = { LASTLEG_ORDER: '头程尾程订单', WAREHOUSE_TRANSFER: '仓储转运单', RETURN_ORDER: '退货单' }
@@ -24,7 +24,8 @@ export function storageRentSummaryStatus(lot: StorageRentLot) {
return Number(lot.settledAmount) === 0 ? '免租 · 已结算' : '已扣款'
}
export function storageRentBillingShare(quantity: number, lot: StorageRentLot) {
export function storageRentBillingShare(quantity: number, lot: StorageRentLot, charge?: Pick<StorageRentCharge, 'billingQuantity' | 'billingUnit'>) {
if (charge?.billingQuantity != null && charge.billingUnit) return `${charge.billingQuantity} ${charge.billingUnit === 'PALLET' ? '托' : charge.billingUnit}`
if (!(Number(lot.originalQuantity) > 0) || lot.billingQuantity == null) return '—'
const units = Number(quantity) * Number(lot.billingQuantity) / Number(lot.originalQuantity)
return `${Number(units.toFixed(6))} ${lot.billingUnit === 'PALLET' ? '托' : lot.billingUnit}`
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { createTransportDemandForm, transportDemandError, transportDemandRules, buildTransportDemand } from './transportDemandForm'
import { truckStatusLabels } from './truckWorkflow'
describe('truck order validation and state filters', () => {
it('requires both detailed addresses before submitting a truck order', () => {
const form = Object.assign(createTransportDemandForm(), {
vehicleType: 'PALLET_15' as const, origin: 'LA', destination: 'ONT',
originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2',
pickupAt: '2026-11-02T12:00:00', deliveryAt: '2026-11-03T12:00:00', estimatedPalletCount: 1, budgetAmount: 100,
})
expect(transportDemandRules('truck', form)).toMatchObject({ originAddress: [{ required: true }], destinationAddress: [{ required: true }] })
expect(transportDemandError('truck', form)).toBe('请输入取货地址')
form.originAddress = ' 取货街道1号 '
expect(transportDemandError('truck', form)).toBe('请输入送达地址')
form.destinationAddress = ' '
expect(() => buildTransportDemand('truck', form)).toThrow('请输入送达地址')
form.destinationAddress = ' 送达街道2号 '
expect(buildTransportDemand('truck', form)).toMatchObject({ originAddress: '取货街道1号', destinationAddress: '送达街道2号' })
})
it('assigns each filter a distinct Chinese state label', () => {
const labels = Object.values(truckStatusLabels)
expect(new Set(labels).size).toBe(labels.length)
expect(truckStatusLabels.PENDING_DISPATCH).toBe('待排车')
expect(truckStatusLabels.DISPATCHED).toBe('待取货')
expect(labels.every(label => /[\u4e00-\u9fff]/.test(label))).toBe(true)
})
})
@@ -42,6 +42,7 @@ export function createTransportDemandForm(): TransportDemandForm {
}
const requiredText = (kind: TransportDemandKind, form: TransportDemandForm) => ({
...(kind === 'truck' ? { originAddress: '取货地址', destinationAddress: '送达地址' } : {}),
...(kind === 'truck' || form.deliveryMode === 'WAREHOUSE_PICKUP' ? { origin: kind === 'truck' ? '取货地' : '起运地', originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人', originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话' } : {}),
destination: kind === 'truck' ? '送达地' : '目的地',
destinationContactName: kind === 'truck' ? '送达联系人' : '目的地联系人',
@@ -1,9 +1,9 @@
import type { LocalTransportDetail, LocalTransportStatus } from './types'
export const truckStatusLabels: Record<LocalTransportStatus, string> = {
FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', WAITING_INBOUND: '待取货', RECEIVING: '待取货', WAITING_CONSOLIDATION: '待取货',
PALLET_OVERAGE_REVIEW: '待取货', PARTIALLY_ALLOCATED: '待取货', ALLOCATED: '待取货',
PENDING_DISPATCH: '待取货', DISPATCHED: '待取货', IN_TRANSIT: '派送中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消',
FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', 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: '审核并生成排车', arrange: '补建排车单', 'receive-pallets': '登记实收', 'review-pallet-overage': '超托审核', 'allocate-pallets': '分配托盘',
@@ -23,8 +23,8 @@ export type TruckOperation =
/** 拼车来源只读;原单只保留审核入口,排车后的执行动作统一在排车任务完成。 */
export function availableTruckCommands(order: Pick<LocalTransportDetail, 'status' | 'fleetArrangement'>): TruckCommand[] {
if (order.fleetArrangement) return []
if (order.status === 'PENDING_REVIEW') return ['review', 'cancel']
return order.status === 'PENDING_DISPATCH' ? ['arrange', 'cancel'] : []
if (order.status === 'PENDING_REVIEW') return ['review']
return order.status === 'PENDING_DISPATCH' ? ['arrange'] : []
}
export const truckCommandHints: Record<TruckCommand, string> = {