feat(fleet): integrate trip management and fee coverage workflows

This commit is contained in:
2026-10-03 04:35:55 +08:00
parent bca08f8749
commit dc35288a1b
87 changed files with 3774 additions and 233 deletions
+2
View File
@@ -43,6 +43,8 @@
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
- 排车转派送弹框打开时并行读取一次`getDispatchExternalFreight`和`getDispatchFeeCoverage`;不逐行查费用。`DeliveryFeeCoveragePanel`按实际各票原费用明确勾选派送/装车/买约,提交来源ID/版本/hash与货物摘要,不按金额推断覆盖。`fullyCoveredServices`只控制输入展示,服务端重新核验;只有全部货物已包含的项目才不提交新增应收,部分覆盖仍需填剩余项目。外接报价只读并提交externalFreightHash。派送详情显示确认时的原费用覆盖快照,原费用金额与本次新增费用分开展示;不能将原费用再次记入新费用行。
- 出库取消提示先解除车次关联,取消后仍须在排车列表释放原排车占用;不提示库存已自动回滚。派送详情费用读取reviewStatus/sourceStatus/voidStatus,同一详情响应展示待财务确认、待财务处理、确认作废,不逐行查询,也不把取消成功视为费用作废。货柜费用面板直接消费同一费用来源接口,待确认时同样禁止调整和重复作废,沿用原页面/弹框承载。
- 排车列表编辑、托盘移除、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;智能预排的候选、草稿柜与托盘锁从 `components/SmartDispatchDialog.vue` 进入。回归为 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts`,不要把列表操作与预排草稿当作同一入口。
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
- 索引变更从后端根运行 `./scripts/agents-index-contract.ps1`。离线检查不代替真实 HTTP 和人工截图验收。
@@ -57,3 +57,26 @@
| 进销存记录 | 导出 | 页面 989 行“导出Excel”按钮 | 工具栏动作,原型未定义导出任务载体 | File 导出类型与海外接口未冻结,本批不显示可成功导出入口 |
`overseas-inbound`、`overseas-relabel` 等原型库存视图用于联查入仓/换标库存事实,不新增重复菜单页;当前正式入库单、换标单、库存列表与详情已经分别承载对应入口。`purchase-stock*` 和 `fba-return-stock` 是否作为独立正式页面尚无本轮导航与接口契约,不在本批自行扩展。
### 车辆外接运费增量(2026-10-03)
| 页面 | 操作 | 依据 | 承载方式 | 本次变更 | 验收 |
|---|---|---|---|---|---|
| 排车列表 | 转派送费用核对 | 用户新版原型12374行1120px居中弹框、12404行ove-panel-delivery费用分区;当前对话确认原费用覆盖、新增项目收费 | 保留现有1120px三列居中转派送弹框 | 费用分区带出外接实收报价,全部外接不重复填写派送应收;无新增抽屉或页面 | 本地交互测试通过,1920×1080实际页面待验收 |
### 取消费用确认增量(2026-10-03)
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|---|---|---|---|---|
| 出库列表 | 取消确认 | 沿现有居中原因确认框;本轮首版费用规则 | 提示先解除车次、费用申请与财务结果分别处理,不再提示库存已自动回滚 | 交互检查通过,真实页面待验收 |
| 派送详情 / 货柜费用面板 | 费用只读及作废 | 沿现有详情费用表与原因弹框;财务确认状态为本轮服务端契约补充 | 展示待财务确认/待财务处理/已作废,待决时关闭重复作废和调整 | 前端检查通过,1920×1080待验收 |
### 原运费覆盖增量(2026-10-03)
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|---|---|---|---|---|
| 排车列表 | 转派送费用确认 | 用户已确认逐票原费用覆盖;复用新版原型12374行1120px三列居中弹框 | DeliveryFeeCoveragePanel逐票显示原费和服务勾选,新增费用仅针对未覆盖部分;不新增抽屉或路由 | 交互测试通过,1920×1080真实页面待验收 |
| 派送详情 | 费用只读 | 沿现有费用页签 | 展示确认时原费版本与服务,原金额不计作本次新增 | 本地展示测试通过,真实页面待验收 |
@@ -65,9 +65,9 @@ export interface DeliveryOrderRow {
}
export interface DeliverySkuRow { inboundCode?: string | null; customerName?: string | null; destinationWarehouseCode?: string | null; receivingAddress?: string | null; shipmentNo?: string | null; fbaTrackingNo?: string | null; skuCode?: string | null; productName?: string | null; packageCount: number; productQuantity?: number | null; weightKg?: number | null; volumeCbm?: number | null }
export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: string; currency: string; amount: number; remark?: string | null }
export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: string; currency: string; amount: number; remark?: string | null; reviewStatus?: string | null; sourceStatus?: string | null; voidStatus?: string | null }
export interface DeliveryOperationRow { id: string; operatedAt: string; operationNode: string; operatorName?: string | null; result: string }
export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[] }
export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[]; feeCoverage?: import('../feeCoverageTypes').FeeCoverageEvidence | null }
export interface DeliveryReturnSource {
outboundPalletItemId: string
@@ -1,10 +1,21 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
describe('dispatch overseas API', () => {
it('reads external freight from the scoped dispatch without a supplied customer', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] } })
expect(await getDispatchExternalFreight('DP/1')).toEqual({ snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] })
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/external-freight')
})
it('loads per-shipment coverage without exposing a caller-supplied customer or losing long IDs', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { dispatchNo: 'DP/1', sourceComplete: true, blockReason: null, cargoHash: 'hash', cargo: [], feeSources: [{ financeFeeId: '190000000000000099', sourceFeeId: '190000000000000100', customerId: '101', sourceInboundId: '11', sourceVersion: '2', amount: '100.25' }] } })
const result = await getDispatchFeeCoverage('DP/1')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/fee-coverage')
expect(result.feeSources[0]).toMatchObject({ financeFeeId: '190000000000000099', sourceVersion: 2, amount: 100.25 })
})
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.post).mockReset()
@@ -235,6 +235,9 @@ export async function releaseDispatchDraft(draftToken: string) {
export interface TransferDeliveryItem { deliveryNo: string; deliveryStatus: string; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; palletCount: number; packageCount: number }
export interface TransferDeliveryResult { dispatchNo: string; outboundNo: string; outboundStatus: string; palletCount: number; packageCount: number; deliveries: TransferDeliveryItem[]; replayed: boolean }
export interface TransferDeliveryPayload {
feeCoverageCargoHash?: string
feeCoverage?: import('../feeCoverageTypes').FeeCoverageSelection[]
externalFreightHash?: string
idempotencyKey: string
plannedDeliveryAt: string
deliveryMethod: string
@@ -262,3 +265,24 @@ export async function transferDispatchToDelivery(dispatchNo: string, payload: Tr
)
return unwrapApiResponse(response)
}
export interface ExternalFreightPreview {
snapshotHash: string
requiresDeliveryFee: boolean
quotes: Array<{ bookingNo: string; recordVersion: string | number; incomingNo: string; inboundCode: string;
customerName: string; destinationKey: string; currency: string; amount: number; quantity: number; quantityUnit: string; unitPrice: number }>
}
export async function getDispatchExternalFreight(dispatchNo: string): Promise<ExternalFreightPreview> {
const response = await http.get<ApiEnvelope<ExternalFreightPreview>, ApiEnvelope<ExternalFreightPreview>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/external-freight`,
)
return unwrapApiResponse(response)
}
export async function getDispatchFeeCoverage(dispatchNo: string): Promise<import('../feeCoverageTypes').FeeCoveragePreview> {
const response = await http.get<ApiEnvelope<import('../feeCoverageTypes').FeeCoveragePreview>, ApiEnvelope<import('../feeCoverageTypes').FeeCoveragePreview>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/fee-coverage`,
)
const data = unwrapApiResponse(response)
return { ...data, cargo: data.cargo.map(row => ({ ...row, dispatchItemId: String(row.dispatchItemId), warehousePalletId: String(row.warehousePalletId), orderId: row.orderId == null ? null : String(row.orderId), sourceInboundId: row.sourceInboundId == null ? null : String(row.sourceInboundId), packages: Number(row.packages) })), feeSources: data.feeSources.map(row => ({ ...row, financeFeeId: String(row.financeFeeId), sourceFeeId: String(row.sourceFeeId), customerId: String(row.customerId), sourceInboundId: String(row.sourceInboundId), sourceVersion: Number(row.sourceVersion), amount: Number(row.amount) })) }
}
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { CoveredService, FeeCoveragePreview, FeeCoverageSelection, OriginalFeeSource } from '../feeCoverageTypes'
import { coveredServiceLabels } from '../feeCoverageTypes'
const props = defineProps<{ preview: FeeCoveragePreview | null; disabled?: boolean; loading?: boolean }>()
const selection = defineModel<FeeCoverageSelection[]>({ default: () => [] })
function selected(id: string) { return selection.value.find(row => row.financeFeeId === id)?.services || [] }
function inboundCode(source: OriginalFeeSource) { return props.preview?.cargo.find(row => row.sourceInboundId === source.sourceInboundId)?.inboundCode || source.inboundCode || '—' }
function change(id: string, services: CoveredService[]) {
const source = props.preview?.feeSources.find(row => row.financeFeeId === id)
if (!source) return
selection.value = [...selection.value.filter(row => row.financeFeeId !== id), ...(services.length ? [{ financeFeeId: id, sourceVersion: source.sourceVersion, sourceHash: source.sourceHash, services: [...services].sort() }] : [])].sort((a, b) => a.financeFeeId.localeCompare(b.financeFeeId))
}
</script>
<template>
<section aria-label="原运费服务覆盖" class="coverage-panel">
<h3>原运费服务覆盖</h3>
<p>仅勾选已与客户明确约定包含的服务。原费用金额供核对,不是本次新增金额;同托多票须分别确认。</p>
<p v-if="loading">正在读取原费用…</p>
<p v-else-if="!preview">原费用尚未读取,请重新打开后核对。</p>
<p v-else-if="!preview.sourceComplete">{{ preview.blockReason }};当前不能引用原费用。</p>
<p v-else-if="!preview?.feeSources.length">暂无可引用的已确认原费用。</p>
<div v-for="source in preview?.feeSources || []" :key="source.financeFeeId" class="coverage-row">
<span>{{ inboundCode(source) }} · {{ source.feeItemName || '—' }} · {{ source.currency }} {{ source.amount }} · 版本 {{ source.sourceVersion }}</span>
<el-checkbox-group :model-value="selected(source.financeFeeId)" :disabled="disabled || !preview?.sourceComplete" @update:model-value="change(source.financeFeeId, $event as CoveredService[])">
<el-checkbox v-for="(label, code) in coveredServiceLabels" :key="code" :value="code">已含{{ label }}</el-checkbox>
</el-checkbox-group>
</div>
</section>
</template>
<style scoped>
.coverage-panel { margin-bottom: 12px; }
.coverage-panel p { margin: 6px 0; color: var(--el-text-color-secondary); font-size: 12px; }
.coverage-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 6px 0; border-bottom: 1px solid var(--el-border-color-lighter); font-size: 12px; }
.coverage-row > span { min-width: 0; overflow-wrap: anywhere; }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { physicalWeightLb, totalPhysicalWeightLb } from '../dispatchPhysicalWeight'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { GripVertical, Plus } from '@lucide/vue'
@@ -87,15 +88,14 @@ const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)
const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const selectedVehicleSpec = computed(() => dispatchVehicleSpecs.find((item) => item.value === selectedVehicle.value))
function weightKg(item?: DispatchPalletCandidateDto) {
const grossWeightKg = Number(item?.grossWeightKg)
if (Number.isFinite(grossWeightKg) && grossWeightKg > 0) return grossWeightKg
const volumeWeightKg = Number(item?.volumeWeightKg)
return Number.isFinite(volumeWeightKg) && volumeWeightKg > 0 ? volumeWeightKg : 0
const weightLb = (item?: DispatchPalletCandidateDto) => physicalWeightLb(item?.grossWeightKg)
const orderWeightLb = (pallets: DispatchPalletCandidateDto[]) => totalPhysicalWeightLb(pallets)
const containerWeightLb = (container: LoadingContainer) => totalPhysicalWeightLb(container.palletIds.map(id => palletSnapshot(id)))
const formatWeightLb = (weight: number | null) => weight === null ? '待确认' : `${number(weight, 0)}LB`
const containerOverweight = (container: LoadingContainer) => {
const weight = containerWeightLb(container)
return weight !== null && weight > container.maxWeightLb * 1.1
}
const weightLb = (item?: DispatchPalletCandidateDto) => weightKg(item) * 2.2046226218
const orderWeightLb = (pallets: DispatchPalletCandidateDto[]) => pallets.reduce((sum, item) => sum + weightLb(item), 0)
const containerWeightLb = (container: LoadingContainer) => container.palletIds.reduce((sum, id) => sum + weightLb(palletSnapshot(id)), 0)
function palletId(item: DispatchPalletCandidateDto) {
return String(item.planPalletId || item.warehousePalletId || '')
@@ -234,9 +234,13 @@ async function movePallet(item: DispatchPalletCandidateDto, containerId: string)
ElMessage.warning('该装车托盘已达到设置的托数')
return
}
const nextWeight = containerWeightLb(target) + weightLb(item)
if (nextWeight > target.maxWeightLb * 1.1) ElMessage.error(`${target.vehicleLabel}重量将达到 ${nextWeight.toFixed(0)}LB,已超过限重10%,请注意调整`)
else if (nextWeight > target.maxWeightLb) ElMessage.warning(`${target.vehicleLabel}当前重量 ${nextWeight.toFixed(0)}LB,已超过额定限重 ${target.maxWeightLb}LB`)
const currentWeight = containerWeightLb(target), incomingWeight = weightLb(item)
if (currentWeight === null || incomingWeight === null) ElMessage.warning('存在未确认的实际毛重,请在正式配载前补齐;材积重不能用于载重校验')
else {
const nextWeight = currentWeight + incomingWeight
if (nextWeight > target.maxWeightLb * 1.1) ElMessage.error(`${target.vehicleLabel}重量将达到 ${nextWeight.toFixed(0)}LB,已超过限重10%,请注意调整`)
else if (nextWeight > target.maxWeightLb) ElMessage.warning(`${target.vehicleLabel}当前重量 ${nextWeight.toFixed(0)}LB,已超过额定限重 ${target.maxWeightLb}LB`)
}
const anchor = assigned.value[0]
if (anchor?.customerId && anchor.customerId !== item.customerId) {
ElMessage.warning('一次排车只能装同一客户的托盘')
@@ -434,7 +438,7 @@ watch([warehouseTab, () => query.orderType], () => {
><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column
>
<el-table-column label="重量(lb)" width="100" align="right"
><template #default="{ row }">{{ number(orderWeightLb(row.pallets), 0) }}LB</template></el-table-column
><template #default="{ row }">{{ formatWeightLb(orderWeightLb(row.pallets)) }}</template></el-table-column
>
<el-table-column label="托盘数量" width="90" align="right"
><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column
@@ -479,7 +483,7 @@ watch([warehouseTab, () => query.orderType], () => {
<GripVertical :size="16" class="drag-handle" />
<div class="pallet-copy">
<strong>{{ text(item.palletNo) }}</strong
><span>{{ Number(item.packageCount || 0) }}箱 · {{ number(weightLb(item), 0) }}LB</span>
><span>{{ Number(item.packageCount || 0) }}箱 · {{ formatWeightLb(weightLb(item)) }}</span>
<div v-if="relabelCartonMarks(item).length" class="carton-marks">
<div v-for="mark in relabelCartonMarks(item)" :key="`relabel-${mark}`" class="carton-mark-line">
<span>{{ mark }}</span
@@ -518,7 +522,7 @@ watch([warehouseTab, () => query.orderType], () => {
:key="container.id"
class="loading-container"
:class="{
'loading-container--overweight': containerWeightLb(container) > container.maxWeightLb * 1.1,
'loading-container--overweight': containerOverweight(container),
}"
@dragover.prevent
@drop.prevent="dropInto($event, container.id)"
@@ -527,7 +531,7 @@ watch([warehouseTab, () => query.orderType], () => {
<strong>{{ container.vehicleLabel }}</strong
><span
>托数 <b>{{ container.palletIds.length }}</b
>/{{ container.capacity }} · 重量 <b>{{ number(containerWeightLb(container), 0) }}LB</b>/{{ container.maxWeightLb }}LB <em v-if="containerWeightLb(container) > container.maxWeightLb * 1.1">超限10%</em></span
>/{{ container.capacity }} · 重量 <b>{{ formatWeightLb(containerWeightLb(container)) }}</b>/{{ container.maxWeightLb }}LB <em v-if="containerOverweight(container)">超限10%</em></span
><el-button class="density-action" data-tone="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)">删除</el-button>
</div>
<div class="container-body">
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { physicalWeightKg, physicalWeightLb, totalPhysicalWeightLb } from './dispatchPhysicalWeight'
describe('dispatch physical weight', () => {
it('keeps absent or invalid physical weight unknown', () => {
for (const value of [null, undefined, '', 0, -1, Number.NaN, Number.POSITIVE_INFINITY]) expect(physicalWeightKg(value)).toBeNull()
expect(physicalWeightLb(10)).toBeCloseTo(22.046226218)
})
it('never substitutes billing volume weight and does not publish partial totals', () => {
const unknown = { grossWeightKg: null, volumeWeightKg: 999 }
expect(totalPhysicalWeightLb([{ grossWeightKg: 20 }, unknown])).toBeNull()
expect(totalPhysicalWeightLb([undefined])).toBeNull()
expect(totalPhysicalWeightLb([{ grossWeightKg: 20 }, { grossWeightKg: 30 }])).toBeCloseTo(110.23113109)
expect(totalPhysicalWeightLb([])).toBe(0)
})
})
@@ -0,0 +1,22 @@
const kilogramsToPounds = 2.2046226218
export function physicalWeightKg(value: unknown): number | null {
if (value == null || value === '') return null
const number = Number(value)
return Number.isFinite(number) && number > 0 ? number : null
}
export function physicalWeightLb(value: unknown): number | null {
const weight = physicalWeightKg(value)
return weight === null ? null : weight * kilogramsToPounds
}
export function totalPhysicalWeightLb(items: readonly ({ grossWeightKg?: number | null } | undefined)[]): number | null {
let total = 0
for (const item of items) {
const weight = physicalWeightLb(item?.grossWeightKg)
if (weight === null) return null
total += weight
}
return total
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { fullyCoveredServices, type FeeCoveragePreview, type FeeCoverageSelection } from './feeCoverageTypes'
const preview: FeeCoveragePreview = {
dispatchNo: 'DP-1', sourceComplete: true, blockReason: null, cargoHash: 'hash',
cargo: ['11', '12'].map(id => ({ dispatchItemId: '201', warehousePalletId: '501', palletNo: 'PLT-1', orderId: id, sourceRegion: 'FIRSTLEG', sourceInboundId: id, inboundCode: `IN-${id}`, packages: 10, dispatchInboundCode: 'IN-11' })),
feeSources: ['11', '12'].map(id => ({ financeFeeId: `7${id}`, sourceFeeId: `8${id}`, sourceType: 'INBOUND_FEE', sourceVersion: 1, customerId: '101', sourceInboundId: id, inboundCode: `IN-${id}`, feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'source-hash', status: 'AVAILABLE' })),
}
describe('per-inbound service coverage', () => {
it('does not hide new fees when only one of two shared-pallet sources is covered', () => {
const selections: FeeCoverageSelection[] = [{ financeFeeId: '711', sourceVersion: 1, sourceHash: 'source-hash', services: ['DELIVERY_FEE'] }]
expect(fullyCoveredServices(preview, selections, [])).toEqual([])
selections.push({ financeFeeId: '712', sourceVersion: 1, sourceHash: 'source-hash', services: ['DELIVERY_FEE'] })
expect(fullyCoveredServices(preview, selections, [])).toEqual(['DELIVERY_FEE'])
})
it('external freight covers delivery only, and incomplete sources never imply original fee coverage', () => {
expect(fullyCoveredServices(preview, [], ['IN-11'])).toEqual(['DELIVERY_FEE'])
expect(fullyCoveredServices({ ...preview, sourceComplete: false }, [], ['IN-11'])).toEqual([])
})
})
@@ -0,0 +1,29 @@
export type CoveredService = 'DELIVERY_FEE' | 'LOADING_FEE' | 'APPOINTMENT_FEE'
export const coveredServiceLabels: Record<CoveredService, string> = { DELIVERY_FEE: '派送', LOADING_FEE: '装车', APPOINTMENT_FEE: '买约' }
export interface CoverageCargo {
dispatchItemId: string; warehousePalletId: string; palletNo: string | null; orderId: string | null
sourceRegion: string | null; sourceInboundId: string | null; inboundCode: string | null; packages: number; dispatchInboundCode: string | null
}
export interface OriginalFeeSource {
financeFeeId: string; sourceFeeId: string; sourceType: string; sourceVersion: number; customerId: string
sourceInboundId: string; inboundCode: string | null; feeItemCode: string; feeItemName: string; currency: string
amount: number; sourceHash: string; status: string
}
export interface FeeCoveragePreview { dispatchNo: string; sourceComplete: boolean; blockReason: string | null; cargoHash: string; cargo: CoverageCargo[]; feeSources: OriginalFeeSource[] }
export interface FeeCoverageSelection { financeFeeId: string; sourceVersion: number; sourceHash: string; services: CoveredService[] }
export interface FeeCoverageEvidence { cargoHash: string; cargo: CoverageCargo[]; references: Array<{ source: OriginalFeeSource; services: CoveredService[] }>; confirmedAt: string }
export function fullyCoveredServices(preview: FeeCoveragePreview | null, selections: FeeCoverageSelection[], externalIncomingNos: string[]): CoveredService[] {
if (!preview?.sourceComplete || !preview.cargo.length) return []
const byOrigin = new Map<string, Set<CoveredService>>()
for (const selection of selections) {
const source = preview.feeSources.find(fee => fee.financeFeeId === selection.financeFeeId)
if (!source) continue
const included = byOrigin.get(source.sourceInboundId) || new Set<CoveredService>()
selection.services.forEach(service => included.add(service))
byOrigin.set(source.sourceInboundId, included)
}
return (Object.keys(coveredServiceLabels) as CoveredService[]).filter(service => preview.cargo.every(cargo =>
service === 'DELIVERY_FEE' && externalIncomingNos.includes(cargo.dispatchInboundCode || '')
|| cargo.sourceRegion === 'FIRSTLEG' && !!cargo.sourceInboundId && byOrigin.get(cargo.sourceInboundId)?.has(service)))
}
@@ -19,8 +19,8 @@ describe('DeliveryOrderDetailView', () => {
createdAt: '2026-09-01T10:00:00',
},
skus: [], fees: [
{ id: 'fee-private', feeDirection: 'RECEIVABLE', feeType: 'PRIVATE_LAST_MILE_DELIVERY', currency: 'USD', amount: 88 },
{ id: 'fee-delivery', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 66 },
{ id: 'fee-private', feeDirection: 'RECEIVABLE', feeType: 'PRIVATE_LAST_MILE_DELIVERY', currency: 'USD', amount: 88, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', voidStatus: 'PENDING_FINANCE' },
{ id: 'fee-delivery', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 66, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', voidStatus: 'FINANCE_ATTENTION' },
], operations: [],
})
})
@@ -36,6 +36,9 @@ describe('DeliveryOrderDetailView', () => {
await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click')
expect(wrapper.text()).toContain('私人仓尾程派送费')
expect(wrapper.text()).toContain('派送费用')
expect(wrapper.text()).toContain('作废待财务确认')
expect(wrapper.text()).toContain('作废待财务处理')
expect(wrapper.text()).not.toContain('已作废')
wrapper.unmount()
})
@@ -47,4 +50,17 @@ describe('DeliveryOrderDetailView', () => {
expect(wrapper.text()).toContain('缺少派送单客户范围')
wrapper.unmount()
})
it('shows frozen original coverage separately from new fees', async () => {
const value = await vi.mocked(getDeliveryOrder)('DEL/1', '100')
value.feeCoverage = { cargoHash: 'hash', cargo: [], confirmedAt: '2026-10-03T00:00:00Z', references: [{
source: { financeFeeId: '711', sourceFeeId: '811', sourceType: 'INBOUND_FEE', sourceVersion: 3, customerId: '100', sourceInboundId: '11', inboundCode: 'IN-11', feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'original', status: 'AVAILABLE' }, services: ['DELIVERY_FEE', 'LOADING_FEE'],
}] }
vi.mocked(getDeliveryOrder).mockResolvedValue(value)
const wrapper = mount(DeliveryOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click')
expect(wrapper.text()).toContain('原费用金额供核对,不计作本次新增')
expect(wrapper.text()).toContain('IN-11 · 原运费 · CNY 100 · 版本 3 · 已含派送、装车')
wrapper.unmount()
})
})
@@ -10,7 +10,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { getDeliveryOrder, type DeliveryOrderDetail } from '../api/deliveryOrders'
import { getDeliveryOrder, type DeliveryOrderDetail, type DeliveryFeeRow } from '../api/deliveryOrders'
import { coveredServiceLabels } from '../feeCoverageTypes'
const route = useRoute()
const router = useRouter()
@@ -26,10 +27,17 @@ const methodLabels: Record<string, string> = { FBA_PICKUP: 'FBA自提', CUSTOMER
const pickupLabels: Record<string, string> = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' }
const loadingLabels: Record<string, string> = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' }
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库', CANCEL_OUTBOUND_REASON: '取消原因', CONFIRM_EXTERNAL_FREIGHT: '确认外接运费' }
const resultLabels: Record<string, string> = { CREATED: '已创建' }
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
function feeStatus(fee: DeliveryFeeRow) {
if (fee.voidStatus === 'CONFIRMED') return '已作废'
if (fee.voidStatus === 'PENDING_FINANCE') return '作废待财务确认'
if (fee.voidStatus === 'FINANCE_ATTENTION') return '作废待财务处理'
if (fee.sourceStatus === 'SUPERSEDED') return '已替代'
return ({ APPROVED: '业务已审核', PENDING: '待审核', DRAFT: '草稿', REJECTED: '已驳回' } as Record<string, string>)[fee.reviewStatus || ''] || '—'
}
function displayPerson(value: unknown) { const text = String(value ?? '').trim(); return text && !/^\d+$/.test(text) ? text : '—' }
function quantity(value: number | null | undefined, unit: string) { return value === null || value === undefined ? '—' : `${value} ${unit}` }
function customerConfirmation() { const signedAt = detail.value?.order.signedAt; return signedAt ? `已签收 · ${formatOverseasDateTime(signedAt)}` : '—' }
@@ -95,10 +103,14 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>{{ `费用明细 (${detail?.fees.length || 0})` }}</span></template>
<section class="tab-content" aria-label="派送单费用明细">
<template v-if="detail?.feeCoverage?.references.length">
<p>本次已确认的原费用覆盖({{ formatOverseasDateTime(detail.feeCoverage.confirmedAt) }}):原费用金额供核对,不计作本次新增。</p>
<p v-for="reference in detail.feeCoverage.references" :key="`${reference.source.financeFeeId}-${reference.services.join('-')}`">{{ detail.feeCoverage.cargo.find(cargo => cargo.sourceInboundId === reference.source.sourceInboundId)?.inboundCode || reference.source.inboundCode || '—' }} · {{ reference.source.feeItemName || '—' }} · {{ reference.source.currency }} {{ reference.source.amount }} · 版本 {{ reference.source.sourceVersion }} · 已含{{ reference.services.map(service => coveredServiceLabels[service]).join('、') }}</p>
</template>
<el-table :data="detail?.fees || []" border stripe empty-text="暂无费用明细">
<el-table-column type="index" label="序号" width="80" /><el-table-column label="应收/应付" width="120"><template #default="scope">{{ scope.row.feeDirection === 'PAYABLE' ? '应付' : scope.row.feeDirection === 'RECEIVABLE' ? '应收' : show(scope.row.feeDirection) }}</template></el-table-column>
<el-table-column label="费用类型" min-width="180"><template #default="scope">{{ feeLabels[scope.row.feeType] || scope.row.feeType }}</template></el-table-column><el-table-column prop="currency" label="币种" width="100" />
<el-table-column prop="amount" label="金额" min-width="140" align="right" /><el-table-column prop="remark" label="备注" min-width="240" />
<el-table-column prop="amount" label="金额" min-width="140" align="right" /><el-table-column label="费用状态" min-width="165"><template #default="scope">{{ feeStatus(scope.row as DeliveryFeeRow) }}</template></el-table-column><el-table-column prop="remark" label="备注" min-width="240" />
</el-table>
</section>
</el-tab-pane>
@@ -29,4 +29,15 @@ describe('DispatchDetailView', () => {
expect(table.props('data')).toEqual([{ orderNo: 'LM-1', weight: 88.5, palletCount: 1, fbaWarehouse: 'ONT8', volume: 1.25, containerNo: '—' }])
wrapper.unmount()
})
it('keeps related order weight unknown when one pallet lacks physical weight', async () => {
vi.mocked(getDispatch).mockResolvedValueOnce({ id: '1', dispatchNo: 'PC/1', status: 'PENDING_DEPARTURE', items: [
{ id: '11', planPalletId: '31', orderNo: 'LM-1', warehousePalletId: '21', grossWeightKg: 88.5, volumeCbm: 1 },
{ id: '12', planPalletId: '32', orderNo: 'LM-1', warehousePalletId: '22', grossWeightKg: null, volumeWeightKg: 999, volumeCbm: 1 },
] })
const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
await flushPromises()
expect(wrapper.findComponent(DataTableStub).props('data')).toEqual([expect.objectContaining({ weight: null })])
wrapper.unmount()
})
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { physicalWeightKg } from '../dispatchPhysicalWeight'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -13,7 +14,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
interface DetailField { label: string; value: string | number }
interface RelatedOrderRow {
orderNo: string
weight: number
weight: number | null
palletCount: number
fbaWarehouse: string
volume: number
@@ -84,21 +85,22 @@ const documentFields = computed<DetailField[]>(() => [
])
const relatedOrders = computed<RelatedOrderRow[]>(() => {
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; weight: number; volume: number }>()
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; weight: number | null; volume: number }>()
for (const item of detail.value?.items || []) {
for (const orderNo of orderReferences(item)) {
const current = grouped.get(orderNo) || { pallets: new Set<string>(), destinations: new Set<string>(), weight: 0, volume: 0 }
current.pallets.add(String(item.warehousePalletId || item.planPalletId || item.id))
const destination = text(item.fbaWarehouseCode || item.destinationWarehouseCode || item.destinationWarehouseName)
if (destination !== '—') current.destinations.add(destination)
current.weight += Number(item.grossWeightKg || 0)
const itemWeight = physicalWeightKg(item.grossWeightKg)
current.weight = current.weight === null || itemWeight === null ? null : current.weight + itemWeight
current.volume += Number(item.volumeCbm || 0)
grouped.set(orderNo, current)
}
}
return [...grouped.entries()].map(([orderNo, value]) => ({
orderNo,
weight: Number(value.weight.toFixed(3)),
weight: value.weight === null ? null : Number(value.weight.toFixed(3)),
palletCount: value.pallets.size,
fbaWarehouse: [...value.destinations].join('、') || '—',
volume: Number(value.volume.toFixed(3)),
@@ -1,16 +1,18 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElFormItem, ElDatePicker, ElSelect, ElCheckbox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
import DispatchesView from './DispatchesView.vue'
const canPermission = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), getDispatchExternalFreight: vi.fn(), getDispatchFeeCoverage: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
class ResizeObserverStub implements ResizeObserver {
observe() {}
@@ -52,6 +54,8 @@ describe('DispatchesView', () => {
vi.mocked(removeDispatchItem).mockReset()
vi.mocked(cancelDispatch).mockReset()
vi.mocked(transferDispatchToDelivery).mockReset()
vi.mocked(getDispatchExternalFreight).mockReset().mockResolvedValue({ snapshotHash: 'own-hash', requiresDeliveryFee: true, quotes: [] })
vi.mocked(getDispatchFeeCoverage).mockReset().mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'cargo-hash', cargo: [], feeSources: [] })
canPermission.mockReset().mockReturnValue(true)
})
@@ -150,6 +154,54 @@ describe('DispatchesView', () => {
expect(source).toContain('请确认海外尾程排车接口已发布')
})
it('loads external quotes only when opened and submits their hash without a duplicate delivery charge', async () => {
vi.mocked(getDispatchExternalFreight).mockResolvedValue({ snapshotHash: 'external-hash', requiresDeliveryFee: false, quotes: [
{ bookingNo: 'FB-1', recordVersion: '4', incomingNo: 'WT-1', inboundCode: 'IN-1', customerName: '客户甲', destinationKey: 'FBA:ONT8', currency: 'CNY', amount: 60, quantity: 2, quantityUnit: '托位', unitPrice: 30 },
] })
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getDispatchExternalFreight).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click')
await flushPromises()
expect(getDispatchExternalFreight).toHaveBeenCalledOnce()
expect(getDispatchExternalFreight).toHaveBeenCalledWith('PC-1')
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
expect(dialog.text()).toContain('FB-1')
expect(dialog.text()).not.toContain('请输入派送费用')
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
}
await flushPromises()
dialog.vm.$emit('confirm')
await flushPromises()
expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], externalFreightHash: 'external-hash' }))
})
it('confirms an original delivery fee without submitting a new receivable', async () => {
vi.mocked(getDispatchFeeCoverage).mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'actual-cargo',
cargo: [{ dispatchItemId: '201', warehousePalletId: '501', palletNo: 'PLT-1', orderId: '401', sourceRegion: 'FIRSTLEG', sourceInboundId: '11', inboundCode: 'IN-11', packages: 20, dispatchInboundCode: 'IN-11' }],
feeSources: [{ financeFeeId: '711', sourceFeeId: '811', sourceType: 'INBOUND_FEE', sourceVersion: 3, customerId: '101', sourceInboundId: '11', inboundCode: 'IN-11', feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'original-fee-hash', status: 'AVAILABLE' }],
})
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getDispatchFeeCoverage).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click')
await flushPromises()
expect(getDispatchFeeCoverage).toHaveBeenCalledTimes(1)
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
await dialog.findComponent(DeliveryFeeCoveragePanel).findAllComponents(ElCheckbox).find(row => row.text() === '已含派送')!.find('input').setValue(true)
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
}
await flushPromises()
expect(dialog.text()).toContain('本次全部货物已覆盖:派送')
dialog.vm.$emit('confirm')
await flushPromises()
expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], feeCoverageCargoHash: 'actual-cargo', feeCoverage: [{ financeFeeId: '711', sourceVersion: 3, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }] }))
})
it('gates edit, cancel and transfer with independent permissions and service statuses', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
expect(source).toContain('permission.dispatchEdit')
@@ -14,7 +14,9 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto, type ExternalFreightPreview } from '../api/dispatches'
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
import { fullyCoveredServices, coveredServiceLabels, type FeeCoveragePreview, type FeeCoverageSelection } from '../feeCoverageTypes'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { DispatchListItem } from '../types'
import { formatOverseasDateTime } from '../lastMilePresentation'
@@ -38,6 +40,15 @@ const filters = ref<QueryModel>({
const transferOpen = ref(false)
const transferRow = ref<DispatchListItem | null>(null)
const transferSaving = ref(false)
const externalFreight = ref<ExternalFreightPreview | null>(null)
const coveragePreview = ref<FeeCoveragePreview | null>(null)
const coverageSelection = ref<FeeCoverageSelection[]>([])
const coveredServices = computed(() => fullyCoveredServices(coveragePreview.value, coverageSelection.value, externalFreight.value?.quotes.map(row => row.incomingNo) || []))
const requiresDeliveryFee = computed(() => !!externalFreight.value?.requiresDeliveryFee && !coveredServices.value.includes('DELIVERY_FEE'))
const requiresLoadingFee = computed(() => needsLoadingFee.value && !coveredServices.value.includes('LOADING_FEE'))
const requiresAppointmentFee = computed(() => isBuyAppointment.value && !coveredServices.value.includes('APPOINTMENT_FEE'))
const freightLoading = ref(false)
let freightGeneration = 0
const mutationError = ref<ApiErrorDetails | null>(null)
const editOpen = ref(false)
const editRow = ref<DispatchListItem | null>(null)
@@ -373,7 +384,7 @@ function openEdit(row: DispatchListItem) {
mutationError.value = null
editOpen.value = true
}
function openTransfer(row: DispatchListItem) {
async function openTransfer(row: DispatchListItem) {
if (!canTransfer(row)) return
transferRow.value = row
Object.assign(transferForm, {
@@ -397,6 +408,19 @@ function openTransfer(row: DispatchListItem) {
})
mutationError.value = null
transferOpen.value = true
externalFreight.value = null
coveragePreview.value = null
coverageSelection.value = []
freightLoading.value = true
const generation = ++freightGeneration
try {
const [preview, coverage] = await Promise.all([getDispatchExternalFreight(row.dispatchNo), getDispatchFeeCoverage(row.dispatchNo)])
if (generation === freightGeneration && transferOpen.value) { externalFreight.value = preview; coveragePreview.value = coverage }
} catch (cause) {
if (generation === freightGeneration && transferOpen.value) mutationError.value = getApiErrorDetails(cause, '约定运费读取失败,请重新打开转派送')
} finally {
if (generation === freightGeneration) freightLoading.value = false
}
}
async function submitEdit() {
if (!editRow.value || !canEdit(editRow.value) || editSaving.value) return
@@ -494,26 +518,27 @@ function handleRowAction(command: string, row: DispatchListItem) {
}
async function submitTransfer() {
if (!transferRow.value || !canTransfer(transferRow.value)) return
if (transferSaving.value || freightLoading.value || !externalFreight.value) return
if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) {
ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型')
return
}
if (transferForm.deliveryFee == null) {
if (requiresDeliveryFee.value && transferForm.deliveryFee == null) {
ElMessage.warning('请填写派送费用')
return
}
if (needsLoadingFee.value && transferForm.loadingFee == null) {
if (requiresLoadingFee.value && transferForm.loadingFee == null) {
ElMessage.warning('当前装车类型需要填写装车费')
return
}
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || transferForm.appointmentFee == null)) {
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || requiresAppointmentFee.value && 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, '买约费用'],
['DELIVERY_FEE', requiresDeliveryFee.value ? transferForm.deliveryFee : undefined, '派送费用'],
['LOADING_FEE', requiresLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'],
['APPOINTMENT_FEE', requiresAppointmentFee.value ? transferForm.appointmentFee : undefined, '买约费用'],
] as const
const fees = feeInputs
.filter(([, amount]) => amount != null)
@@ -544,6 +569,9 @@ async function submitTransfer() {
sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined,
remark: transferForm.remark.trim() || undefined,
fees,
externalFreightHash: externalFreight.value.snapshotHash,
feeCoverageCargoHash: coverageSelection.value.length ? coveragePreview.value?.cargoHash : undefined,
feeCoverage: coverageSelection.value,
})
transferOpen.value = false
ElMessage.success('已生成出库单和派送单')
@@ -569,7 +597,7 @@ onMounted(load)
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable>
</ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form class="density-form" label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
</ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" :confirm-disabled="freightLoading || !externalFreight" @confirm="submitTransfer"><el-form class="density-form" label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><DeliveryFeeCoveragePanel v-model="coverageSelection" :preview="coveragePreview" :loading="freightLoading" :disabled="transferSaving || freightLoading" /><p v-if="coveredServices.length">本次全部货物已覆盖:{{ coveredServices.map(service => coveredServiceLabels[service]).join('、') }},无需重复登记对应应收。</p><h3>本次新增费用</h3><p v-if="freightLoading">正在读取约定运费…</p><p v-for="quote in externalFreight?.quotes || []" :key="quote.bookingNo">{{ quote.bookingNo }} · {{ quote.incomingNo }}:{{ quote.quantity }} {{ quote.quantityUnit }} × {{ quote.currency }} {{ quote.unitPrice }} = {{ quote.currency }} {{ quote.amount }}(外接约定运费)</p><div class="transfer-grid"><el-form-item v-if="requiresDeliveryFee" :label="externalFreight?.quotes.length || coverageSelection.length ? '派送费(未覆盖部分,USD)' : '派送费用(USD)'" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="requiresLoadingFee" label="装车费(USD)" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="requiresAppointmentFee" label="买约费用(USD)" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="原运费仅按上方确认的服务范围引用;外接运费按实收约定带入。下方只登记未覆盖货物或服务的新增应收。" type="info" :closable="false" /></el-form></AppDialog>
<AppDialog class="admin-density-dialog" v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;未生成出库单前可移除托盘。`" type="info" :closable="false" /><el-form class="density-form" label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" readonly placeholder="按已排托盘自动汇总 FBA 仓" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)">移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></el-table></AppDialog>
</template>
<style scoped>
@@ -118,7 +118,7 @@ async function mountView() {
async function openPending() {
const wrapper = await mountView()
const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')
const pendingTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '待分配')
await pendingTab!.trigger('click')
await flushPromises()
return wrapper
@@ -159,6 +159,74 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
}
describe('LastMileOrdersView pending order and container conversion', () => {
it('supports keyboard status switching and keeps selection and queries in sync', async () => {
const wrapper = await mountView()
const tabs = wrapper.findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['全部', '待分配'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.attributes('tabindex')).toBe('-1')
await selectRows(wrapper, [selectedRow()])
await tabs[0]!.trigger('keydown', { key: 'ArrowRight' })
await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.classes()).toContain('is-active')
expect(document.activeElement).toBe(tabs[1]!.element)
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, currentStatus: 'PENDING_ASSIGNMENT' }))
expect((wrapper.vm as unknown as { selectedRows: unknown[] }).selectedRows).toEqual([])
const callCount = vi.mocked(listLastMileOrders).mock.calls.length
await tabs[1]!.trigger('click')
await flushPromises()
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(callCount)
await tabs[1]!.trigger('keydown', { key: 'Home' })
await flushPromises()
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: undefined }))
await tabs[0]!.trigger('keydown', { key: 'End' })
await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
})
it('preserves advanced conditions when collapsed and clears them on reset', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
expect(toolbar.props('fields').map((field) => field.key)).toEqual(['orderNo', 'inboundCode', 'warehouse'])
const initialCalls = vi.mocked(listLastMileOrders).mock.calls.length
await button(wrapper, '高级筛选')!.trigger('click')
expect(toolbar.props('fields')).toHaveLength(13)
toolbar.vm.$emit('update:modelValue', {
...toolbar.props('modelValue'), containerNo: ' CONT-15 ', abnormal: 'false',
createdAt: ['2026-09-01', '2026-09-30'],
})
await wrapper.vm.$nextTick()
await button(wrapper, '收起筛选')!.trigger('click')
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(initialCalls)
expect(toolbar.props('modelValue').containerNo).toBe(' CONT-15 ')
await button(wrapper, '查询')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({
containerNo: 'CONT-15', abnormal: false,
createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-30T23:59:59',
}))
await button(wrapper, '重置')!.trigger('click')
await flushPromises()
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(initialCalls + 2)
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1, containerNo: undefined, abnormal: undefined, createdFrom: undefined, createdTo: undefined,
}))
await button(wrapper, '高级筛选')!.trigger('click')
expect(toolbar.props('modelValue').containerNo).toBe('')
expect(toolbar.props('modelValue').createdAt).toEqual([])
})
it('keeps container filters separate from the advanced order fields', async () => {
const wrapper = await mountView()
await button(wrapper, '高级筛选')!.trigger('click')
await openHiddenContainerWorkbench(wrapper)
expect(wrapper.findComponent(QueryToolbar).props('fields').map((field) => field.key)).toEqual(['containerNo', 'createdAt'])
expect(button(wrapper, '收起筛选')).toBeUndefined()
expect(button(wrapper, '高级筛选')).toBeUndefined()
})
it('keeps the original carton count and adds only the signed difference in the list', async () => {
canAccess.mockReturnValue(false)
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
@@ -300,7 +368,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
]))
expect(availableFields.map((field) => field.key)).not.toEqual(expect.arrayContaining(['planNo', 'inboundOrderNo']))
await wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')!.trigger('click')
await wrapper.findAll('[role="tab"]').find((item) => item.text() === '待分配')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'PENDING_ASSIGNMENT', lxCode: 'LX-SEARCH' }))
@@ -310,7 +378,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-07T23:59:59',
})
await wrapper.findAll('.el-radio-button').find((item) => item.text() === '全部')!.trigger('click')
await wrapper.findAll('[role="tab"]').find((item) => item.text() === '全部')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'DELIVERED', lxCode: 'LX-SEARCH' }))
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Download, Pencil, PackagePlus } from '@lucide/vue'
import { ChevronDown, ChevronUp, Download, Pencil, PackagePlus, SlidersHorizontal } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
@@ -143,6 +143,8 @@ const receivingWarehouseForm = reactive({ warehouseId: '', remark: '' })
let receivingWarehouseIdempotencyKey = ''
let containerOptionRequest = 0
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
const statusTabs = ['全部', '待分配'] as const
const showAdvancedFilters = ref(false)
const filters = ref<QueryModel>({ orderNo: '', relabelNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const allFields: QueryField[] = [
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
@@ -176,7 +178,9 @@ const allFields: QueryField[] = [
]
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
: allFields)
: showAdvancedFilters.value
? allFields
: allFields.filter((field) => ['orderNo', 'inboundCode', 'warehouse'].includes(field.key)))
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
const selectedReceivingWarehouse = computed(() => receivingWarehouseOptions.value.find((row) => (
row.warehouseId === receivingWarehouseForm.warehouseId
@@ -381,12 +385,35 @@ async function load(model: QueryModel = filters.value) {
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
function reset() {
for (const field of allFields) filters.value[field.key] = field.type === 'daterange' ? [] : ''
page.value = 1
void load()
}
function changeStatusTab() {
selectedRows.value = []
page.value = 1
void load()
}
function selectStatusTab(value: typeof statusTabs[number]) {
if (statusTab.value === value) return
statusTab.value = value
changeStatusTab()
}
function handleStatusTabKeydown(event: KeyboardEvent, index: number) {
let nextIndex = index
if (event.key === 'ArrowRight') nextIndex = (index + 1) % statusTabs.length
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + statusTabs.length) % statusTabs.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = statusTabs.length - 1
else return
event.preventDefault()
const tab = statusTabs[nextIndex]
if (!tab) return
selectStatusTab(tab)
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
}
async function openContainerDetail(row: ContainerPlanProgressSummaryDto) {
containerDetailOpen.value = true
@@ -944,13 +971,22 @@ onMounted(() => { void load() })
<template #toolbar-start><h1>尾程订单</h1></template>
<template #filters>
<section class="last-mile-toolbar integrated-query-group" aria-label="尾程订单状态与筛选">
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
<el-radio-button value="全部">全部</el-radio-button>
<el-radio-button value="待分配">待分配</el-radio-button>
<div class="density-status-tabs" role="tablist" aria-label="尾程订单状态">
<button v-for="(tab, index) in statusTabs" :key="tab" type="button" role="tab"
:aria-selected="statusTab === tab" :tabindex="statusTab === tab ? 0 : -1"
:class="{ 'is-active': statusTab === tab }"
@click="selectStatusTab(tab)" @keydown="handleStatusTabKeydown($event, index)">{{ tab }}</button>
<!-- 柜子工作台入口暂时隐藏,保留现有实现便于后续恢复。 -->
<!-- <el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button> -->
</el-radio-group>
<QueryToolbar v-model="filters" :fields="fields" :available-fields="allFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset" />
</div>
<QueryToolbar id="last-mile-orders-advanced-filters" v-model="filters" :fields="fields" :available-fields="allFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset">
<template v-if="statusTab !== '柜子'" #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="last-mile-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
</el-button>
</template>
</QueryToolbar>
</section>
</template>
<template #notices><template v-if="loadError">
@@ -208,7 +208,7 @@ async function submit() {
}
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(row.status || '')) }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}。已关联车次请先解除关联;取消将申请撤销未履行的派送应收,装车、买约及其他费用保留待核对。`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消;派送应收撤销结果请查看费用明细,排车占用需回排车列表取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
@@ -98,7 +98,7 @@ const tableColumns = computed(() => props.mode === 'cartons' ? cartonColumns : i
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" v-model:page="query.page" v-model:page-size="query.size" :data="tableRows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :preference-key="`overseas-return-${mode === 'inventory' ? inventoryTab : 'cartons'}`" @update:page="load" @update:page-size="query.page = 1; load()">
<template #toolbar-start><h1>{{ mode === 'inventory' ? '退货库存管理' : '退货箱号管理' }}</h1></template>
<template #toolbar-actions><el-button v-if="mode==='inventory'" type="warning" @click="openPackingPlan">创建合箱计划</el-button></template>
<template #filters><el-tabs class="density-element-tabs" v-if="mode==='inventory'" v-model="inventoryTab" @tab-change="changeInventoryTab"><el-tab-pane name="stock" ><template #label><span class="density-tab-icon" aria-hidden="true">🏷️</span><span>退货库存</span></template></el-tab-pane><el-tab-pane name="movements" ><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>退货进销存明细</span></template></el-tab-pane></el-tabs><section class="query-toolbar query-panel"><el-select v-model="query.customerId" clearable filterable placeholder="全部客户" :loading="optionsLoading"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="全部海外仓" :loading="optionsLoading"><el-option v-for="item in warehouseOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.cartonMark" clearable placeholder="箱号/箱唛" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" /><el-select v-if="mode==='cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select><el-button type="primary" @click="search"><Search :size="16" />查询</el-button></section></template>
<template #filters><el-tabs class="density-element-tabs density-status-tabs" v-if="mode==='inventory'" v-model="inventoryTab" @tab-change="changeInventoryTab"><el-tab-pane name="stock" ><template #label><span class="density-tab-icon" aria-hidden="true">🏷️</span><span>退货库存</span></template></el-tab-pane><el-tab-pane name="movements" ><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>退货进销存明细</span></template></el-tab-pane></el-tabs><section class="query-toolbar query-panel"><el-select v-model="query.customerId" clearable filterable placeholder="全部客户" :loading="optionsLoading"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="全部海外仓" :loading="optionsLoading"><el-option v-for="item in warehouseOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.cartonMark" clearable placeholder="箱号/箱唛" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" /><el-select v-if="mode==='cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select><el-button type="primary" @click="search"><Search :size="16" />查询</el-button></section></template>
<template #stock-0="{ row }">{{ customerName(row.customerId) }}</template>
<template #stock-1="{ row }">{{ warehouseName(row.overseasWarehouseId) }}</template>
<template #movement-0="{ row }">{{ customerName(row.customerId) }}</template>
@@ -41,7 +41,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
])
const keys = [...expected.entries()].map(([fileName, preferenceKey]) => {
const value = source(fileName)
const toolbar = value.match(/<QueryToolbar\b[^>]*\/>/)?.[0]
const toolbar = value.match(/<QueryToolbar\b[^>]*>/)?.[0]
expect(toolbar, `${fileName} should render a QueryToolbar`).toBeDefined()
expect(toolbar).toContain(`preference-key="${preferenceKey}"`)
return preferenceKey
@@ -374,8 +374,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain("listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })")
expect(value).toContain('请选择目的 FBA 仓')
expect(value).toContain('aria-label="尾程订单状态"')
expect(value).toContain('<el-radio-button value="全部">全部</el-radio-button>')
expect(value).toContain('<el-radio-button value="待分配">待分配</el-radio-button>')
expect(value).toContain("const statusTabs = ['全部', '待分配'] as const")
expect(value).toContain('class="density-status-tabs" role="tablist"')
const columns = value.slice(value.indexOf('const columns:'), value.indexOf('const selectedContainerIds'))
expect(columns.indexOf("label: '入库单号'")).toBeLessThan(columns.indexOf("label: '出库单号'"))
expect(columns.indexOf("label: '出库单号'")).toBeLessThan(columns.indexOf("label: '派送单号'"))