优化
This commit is contained in:
@@ -518,7 +518,7 @@ async function submitForReview() {
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'提交后将校验测量图片和全部称重结果,并由后端准备托盘与库位占用。提交成功后本批次不能继续编辑,设备也不能再覆盖已提交数据。',
|
||||
'提交成功后本批次不能继续编辑',
|
||||
'提交测量审核',
|
||||
{ customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' },
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading
|
||||
const resource = {
|
||||
id: '123', bookingNo: 'OB-123', status, version, customerId: '456', customerName: '测试客户',
|
||||
billOfLadingNo, shipperName: '发货人', consigneeName: '收货人', containerCount: 2,
|
||||
channelCode: 'SEA_FCL', channelName: '海运整箱', warehouseId: '8', warehouseName: '义乌仓',
|
||||
channelCode: 'MATSON_CLX', channelName: '美森正班', warehouseId: '8', warehouseName: '义乌仓',
|
||||
shippingCompanyId: '10', shippingCompanyName: '测试船公司', carrierCode: 'MATSON' as const,
|
||||
bookingAgentId: '20', bookingAgentName: '订舱代理', vesselName: 'TEST VESSEL', voyageNo: 'V001',
|
||||
departurePortCode: 'CNSHA', departurePortName: '上海', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
@@ -97,10 +97,17 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([{ channelCode: 'SEA_FCL', channelName: '海运整箱' }])
|
||||
vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([{ channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡' }])
|
||||
vi.mocked(listOceanBookingAgents).mockResolvedValue([{ supplierId: '20', name: '订舱代理', carrierCodes: ['MATSON'] }])
|
||||
})
|
||||
|
||||
it('shows the matching fixed logistics group instead of the underlying price-book channel', async () => {
|
||||
const { wrapper } = await mountView()
|
||||
expect(wrapper.text()).toContain('美森正班')
|
||||
expect(wrapper.text()).not.toContain('美森正班海卡')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('saves dirty form data and releases with the returned latest version', async () => {
|
||||
vi.mocked(updateOceanBookingDraft).mockResolvedValue(booking('DRAFT', 4, 'NEW-BL'))
|
||||
vi.mocked(releaseOceanBooking).mockResolvedValue(booking('RELEASED', 5, 'NEW-BL'))
|
||||
|
||||
@@ -20,6 +20,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
|
||||
import { SMART_LOADING_CHANNEL_GROUPS } from '@/modules/container-loading/smartLoadingChannelGroups'
|
||||
import {
|
||||
createOceanBooking,
|
||||
deleteOceanBookingDraft,
|
||||
@@ -87,7 +88,11 @@ const selectableCustomers = computed(() => customers.value.filter((customer) =>
|
||||
customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)
|
||||
)))
|
||||
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.types.includes('CARRIER')))
|
||||
const channelUnavailable = computed(() => Boolean(form.data.warehouseId) && !channelsLoading.value && !channelsError.value && channels.value.length === 0)
|
||||
const channelGroups = computed(() => {
|
||||
const activeCodes = new Set(channels.value.map((channel) => channel.channelCode))
|
||||
return SMART_LOADING_CHANNEL_GROUPS.filter((group) => group.channelCodes.some((code) => activeCodes.has(code)))
|
||||
})
|
||||
const channelUnavailable = computed(() => Boolean(form.data.warehouseId) && !channelsLoading.value && !channelsError.value && channelGroups.value.length === 0)
|
||||
|
||||
function blankParties(): OceanBookingParty[] {
|
||||
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', address: '' }))
|
||||
@@ -187,9 +192,9 @@ async function loadChannels(warehouseId: string) {
|
||||
}
|
||||
}
|
||||
function selectChannel(channelCode: string) {
|
||||
const channel = channels.value.find((item) => item.channelCode === channelCode)
|
||||
const channel = SMART_LOADING_CHANNEL_GROUPS.find((item) => item.code === channelCode)
|
||||
form.data.channelCode = channelCode
|
||||
selectedChannelName.value = channel?.channelName || ''
|
||||
selectedChannelName.value = channel?.name || ''
|
||||
}
|
||||
function clearBookingAgent() {
|
||||
form.data.bookingAgentId = null
|
||||
@@ -397,7 +402,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<el-form-item label="客户(选填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="单客户可选择,多客户可留空" no-data-text="暂无可选客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '保存后为草稿'" disabled /></el-form-item>
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
|
||||
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
|
||||
<el-form-item label="提单号" prop="billOfLadingNo"><el-input v-model="form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
|
||||
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable :disabled="!editable || !form.data.carrierCode || bookingAgentsLoading || !!bookingAgentsError" :placeholder="form.data.carrierCode ? '请选择订舱代理' : '请先选择船公司'" :loading="bookingAgentsLoading" no-data-text="当前船公司暂无可用订舱代理" style="width:100%" @update:model-value="selectBookingAgent(String($event || ''))"><el-option v-if="!editable && form.data.bookingAgentId && !bookingAgents.some((supplier) => supplier.supplierId === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in bookingAgents" :key="supplier.supplierId" :label="supplier.name" :value="supplier.supplierId" /></el-select><div v-if="bookingAgentsError" class="field-error"><ApiErrorMeta :details="bookingAgentsError" /><el-button link type="primary" :loading="bookingAgentsLoading" @click="form.data.carrierCode && loadBookingAgents(form.data.carrierCode)">重新加载</el-button></div></el-form-item>
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
| 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
|
||||
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||
| 排车查询、智能预排与转派送 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。页面能力必须由正式路由、API 调用和测试共同证明 |
|
||||
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库列表按类型筛选并展示中文类型。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
|
||||
| 排车查询、智能预排与转派送 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。一次转派送仍生成一张车辆级出库单,响应的 `deliveries` 按目的 FBA 仓返回多张派送单。页面能力必须由正式路由、API 调用和测试共同证明 |
|
||||
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库详情使用 `deliveries` 展示按目的 FBA 仓拆出的全部派送单,派送列表和详情必须显示目的 FBA 仓。人工状态入口不提供完成派送;签收资料保存成功后提示自动完成并刷新列表。出库列表按类型筛选并展示中文类型。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
|
||||
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
|
||||
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
|
||||
| 托盘、箱唛、产品与换标 | `pages/PalletsView.vue`、`pages/CartonsView.vue`、`pages/ProductsView.vue`、`pages/RelabelOrdersView.vue` 及详情页 | `api/pallets.ts`、`api/relabelOrders.ts`;换标详情展示 PDA 完工照片与复核人,状态区分待客户确认和待出库;路由是否公开以 `routes.ts` 为准 |
|
||||
|
||||
## 履约硬边界
|
||||
|
||||
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;普通出库及换标单经头程排车转出库,按托盘快照绑定头程箱唛,PDA 按托盘作业,详情展示箱唛与已拣数量;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成待出库单
|
||||
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;普通出库及换标单经头程排车转出库,按托盘快照绑定头程箱唛,PDA 按托盘作业,详情展示箱唛与已拣数量;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成一张待出库单及按目的 FBA 仓拆分的派送单
|
||||
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
|
||||
|
||||
## 验证
|
||||
|
||||
@@ -27,10 +27,10 @@ describe('customer visit API', () => {
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/customer-visits/9', { params: { customerId: '100' } })
|
||||
})
|
||||
|
||||
it('updates delivery status so selecting delivered can generate a visit', async () => {
|
||||
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { deliveryStatus: 'DELIVERED' } })
|
||||
await updateDeliveryOrderStatus('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: '完成' })
|
||||
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERED', expectedVersion: 7, remark: '完成' }, { params: { customerId: '100' } })
|
||||
it('updates a delivery to an operator-managed nonterminal status', async () => {
|
||||
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { deliveryStatus: 'DELIVERING' } })
|
||||
await updateDeliveryOrderStatus('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: '开始派送' })
|
||||
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERING', expectedVersion: 7, remark: '开始派送' }, { params: { customerId: '100' } })
|
||||
})
|
||||
|
||||
it('uploads and saves repeatable visit material', async () => {
|
||||
|
||||
@@ -99,14 +99,6 @@ export async function uploadCustomerVisitAttachment(visitId: string, customerId:
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function completeDeliveryOrder(deliveryNo: string, customerId: string) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<{ visitId: string; deliveryNo: string; deliveryStatus: string; visitStatus: string; replayed: boolean }>, ApiEnvelope<{ visitId: string; deliveryNo: string; deliveryStatus: string; visitStatus: string; replayed: boolean }>>(
|
||||
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, undefined,
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function updateDeliveryOrderStatus(deliveryNo: string, customerId: string, payload: { status: string; expectedVersion: number; remark?: string }) {
|
||||
const response = await overseasHttp.put<ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>, ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>>(
|
||||
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, payload,
|
||||
|
||||
@@ -30,6 +30,9 @@ export interface DeliveryOrderRow {
|
||||
deliveryNo: string
|
||||
outboundNo: string
|
||||
dispatchNo: string
|
||||
destinationWarehouseId?: string | null
|
||||
destinationWarehouseCode?: string | null
|
||||
destinationWarehouseName?: string | null
|
||||
overseasWarehouseId?: string | null
|
||||
overseasWarehouseName?: string | null
|
||||
status: string
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('dispatch overseas API', () => {
|
||||
})
|
||||
|
||||
it('transfers to delivery only by dispatchNo without customerId', async () => {
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', deliveryNo: 'DEL-1' } })
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', outboundStatus: 'PICKED', palletCount: 2, packageCount: 20, deliveries: [{ deliveryNo: 'DEL-1', deliveryStatus: 'WAIT_DELIVERY', destinationWarehouseCode: 'ACY2', palletCount: 2, packageCount: 20 }], replayed: false } })
|
||||
const payload = { idempotencyKey: 'transfer-1', plannedDeliveryAt: '2026-09-01T10:00:00', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费用', direction: 'RECEIVABLE' as const, currency: 'USD' as const, amount: 10 }] }
|
||||
await transferDispatchToDelivery('PC/1', payload)
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', payload)
|
||||
|
||||
@@ -225,7 +225,8 @@ export async function releaseDispatchDraft(draftToken: string) {
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export interface TransferDeliveryResult { dispatchNo: string; outboundNo: string; outboundStatus: string; deliveryNo: string; deliveryStatus: string; palletCount: number; packageCount: number; replayed: boolean }
|
||||
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 {
|
||||
idempotencyKey: string
|
||||
plannedDeliveryAt: string
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('outbound overseas API', () => {
|
||||
})
|
||||
|
||||
it('loads detail and cancels only by outboundNo without customerId', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { outbound: { id: '1', outboundNo: 'OUT/1', status: 'PICKED' }, delivery: null } })
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { outbound: { id: '1', outboundNo: 'OUT/1', status: 'PICKED' }, deliveries: [] } })
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: null })
|
||||
await getOutbound('OUT/1')
|
||||
await cancelOutbound('OUT/1', '运营取消')
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface OutboundPalletRow {
|
||||
pickedPackageCount?: number | null
|
||||
status?: string | null
|
||||
}
|
||||
export interface OutboundDetail { outbound: OutboundRow; pallets?: OutboundPalletRow[]; delivery?: DeliveryOrderRow | null }
|
||||
export interface OutboundDetail { outbound: OutboundRow; pallets?: OutboundPalletRow[]; deliveries?: DeliveryOrderRow[] }
|
||||
export interface OutboundQuery { page: number; size: number; documentNo?: string; lxCode?: string; warehouseId?: string; status?: string; outboundType?: string }
|
||||
export interface ReturnOutboundCandidate { returnNo?: string | null; overseasWarehouseId?: string | null; cartonMark?: string | null; fbaCartonMark?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; totalQuantity?: number | null; status?: string | null }
|
||||
export interface OneClickOutboundCandidate { inventoryPositionId: string; orderNo?: string | null; customerId?: string | null; customerName?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; palletNo?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackages: number }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessage } from 'element-plus'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -51,6 +51,7 @@ describe('delivery document drafts', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses real upload controls to add, replace, delete and reload the saved three-category snapshot', async () => {
|
||||
const success = vi.spyOn(ElMessage, 'success')
|
||||
const receipt = { ...originalFile, fileId: 'receipt-1', fileName: 'pod.pdf' }
|
||||
const courier = { ...originalFile, fileId: 'receipt-2', fileName: 'courier.pdf' }
|
||||
const replacement = { ...originalFile, fileId: 'bol-2', fileName: 'signed-bol.pdf' }
|
||||
@@ -72,6 +73,7 @@ describe('delivery document drafts', () => {
|
||||
await dialog.findAll('button').find(button => button.text() === '保存资料')!.trigger('click'); await flushPromises()
|
||||
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL/1', '100', { expectedVersion: 9, idempotencyKey: expect.any(String), bolNo: 'BOL-1', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: ['bol-2'] }, { category: 'RECEIPT', fileIds: ['receipt-2'] }] })
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(success).toHaveBeenCalledWith('签收文件已保存,派送单已自动完成')
|
||||
const persisted = response()
|
||||
persisted.version = 10; persisted.uploadedCategoryCount = 2
|
||||
persisted.categories = persisted.categories.map(group => ({ ...group, files: group.category === 'BOL' ? [replacement] : group.category === 'RECEIPT' ? [courier] : [], fileCount: group.category === 'LOADING_PHOTO' ? 0 : 1, status: group.category === 'LOADING_PHOTO' ? 'MISSING' : 'UPLOADED' }))
|
||||
|
||||
@@ -97,7 +97,8 @@ async function save() {
|
||||
try {
|
||||
await saveDeliveryDocuments(deliveryNo, customerId, { ...body, idempotencyKey: attempt.key })
|
||||
if (!isCurrent(current, deliveryNo, customerId)) return
|
||||
open.value = false; emit('saved'); ElMessage.success('派送资料已保存')
|
||||
const hasReceipt = body.documents.some(item => item.category === 'RECEIPT' && item.fileIds.length > 0)
|
||||
open.value = false; emit('saved'); ElMessage.success(hasReceipt ? '签收文件已保存,派送单已自动完成' : '派送资料已保存')
|
||||
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) { error.value = getApiErrorDetails(cause, '派送资料保存失败'); conflict.value = error.value.code === 'A0409' } }
|
||||
finally { if (current === sequence) saving.value = false }
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('DeliveryOrderDetailView', () => {
|
||||
order: {
|
||||
version: 3, allowedActions: ['COMPLETE_DELIVERY'],
|
||||
id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1',
|
||||
status: 'DELIVERING', overseasWarehouseName: '洛杉矶仓', palletCount: 1, packageCount: 2,
|
||||
status: 'DELIVERING', overseasWarehouseName: '洛杉矶仓', destinationWarehouseCode: 'ACY2', palletCount: 1, packageCount: 2,
|
||||
createdAt: '2026-09-01T10:00:00',
|
||||
},
|
||||
skus: [], fees: [
|
||||
@@ -32,6 +32,7 @@ describe('DeliveryOrderDetailView', () => {
|
||||
expect(wrapper.text()).toContain('DEL/1')
|
||||
expect(wrapper.text()).toContain('派送中')
|
||||
expect(wrapper.text()).toContain('洛杉矶仓')
|
||||
expect(wrapper.text()).toContain('ACY2')
|
||||
await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click')
|
||||
expect(wrapper.text()).toContain('私人仓尾程派送费')
|
||||
expect(wrapper.text()).toContain('派送费用')
|
||||
|
||||
@@ -68,10 +68,11 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
|
||||
<section class="tab-content" aria-label="订单信息">
|
||||
<dl v-if="detail" class="info-grid density-fields">
|
||||
<div class="info-item"><dt>派送单号</dt><dd>{{ show(detail.order.deliveryNo) }}</dd></div><div class="info-item"><dt>状态</dt><dd>{{ statusLabels[detail.order.status] || detail.order.status }}</dd></div>
|
||||
<div class="info-item"><dt>仓库</dt><dd>{{ show(detail.order.overseasWarehouseName) }}</dd></div><div class="info-item"><dt>运单号</dt><dd>{{ show(detail.order.waybillNo) }}</dd></div>
|
||||
<div class="info-item"><dt>派送人</dt><dd>{{ show(detail.order.carrierName) }}</dd></div><div class="info-item"><dt>派送时间</dt><dd>{{ formatOverseasDateTime(detail.order.dispatchedAt || detail.order.plannedDeliveryAt) }}</dd></div>
|
||||
<div class="info-item"><dt>提货方式</dt><dd>{{ pickupLabels[detail.order.pickupMethod || ''] || show(detail.order.pickupMethod) }}</dd></div><div class="info-item"><dt>装车类型</dt><dd>{{ loadingLabels[detail.order.loadingType || ''] || show(detail.order.loadingType) }}</dd></div>
|
||||
<div class="info-item"><dt>派送方式</dt><dd>{{ methodLabels[detail.order.deliveryMethod || ''] || show(detail.order.deliveryMethod) }}</dd></div><div class="info-item"><dt>司机/车队</dt><dd>{{ show(detail.order.driverName) }}</dd></div>
|
||||
<div class="info-item"><dt>仓库</dt><dd>{{ show(detail.order.overseasWarehouseName) }}</dd></div><div class="info-item"><dt>目的 FBA 仓</dt><dd>{{ show(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseName) }}</dd></div>
|
||||
<div class="info-item"><dt>运单号</dt><dd>{{ show(detail.order.waybillNo) }}</dd></div><div class="info-item"><dt>派送人</dt><dd>{{ show(detail.order.carrierName) }}</dd></div>
|
||||
<div class="info-item"><dt>派送时间</dt><dd>{{ formatOverseasDateTime(detail.order.dispatchedAt || detail.order.plannedDeliveryAt) }}</dd></div><div class="info-item"><dt>提货方式</dt><dd>{{ pickupLabels[detail.order.pickupMethod || ''] || show(detail.order.pickupMethod) }}</dd></div>
|
||||
<div class="info-item"><dt>装车类型</dt><dd>{{ loadingLabels[detail.order.loadingType || ''] || show(detail.order.loadingType) }}</dd></div><div class="info-item"><dt>派送方式</dt><dd>{{ methodLabels[detail.order.deliveryMethod || ''] || show(detail.order.deliveryMethod) }}</dd></div>
|
||||
<div class="info-item"><dt>司机/车队</dt><dd>{{ show(detail.order.driverName) }}</dd></div>
|
||||
<div class="info-item"><dt>车牌号</dt><dd>{{ show(detail.order.vehiclePlate) }}</dd></div><div class="info-item"><dt>总箱数</dt><dd>{{ quantity(detail.order.packageCount, '箱') }}</dd></div>
|
||||
<div class="info-item"><dt>总重量</dt><dd>{{ quantity(detail.order.totalWeightKg, 'kg') }}</dd></div><div class="info-item"><dt>总体积</dt><dd>{{ quantity(detail.order.totalVolumeCbm, 'm³') }}</dd></div>
|
||||
<div class="info-item"><dt>预计到达时间</dt><dd>{{ formatOverseasDateTime(detail.order.expectedArrivalAt) }}</dd></div><div class="info-item"><dt>客户确认</dt><dd>{{ customerConfirmation() }}</dd></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { defineComponent, h, type PropType } from 'vue'
|
||||
import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDeliveryOrder, listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from '../api/deliveryOrders'
|
||||
import { updateDeliveryOrderStatus } from '../api/customerVisits'
|
||||
@@ -54,10 +54,10 @@ describe('DeliveryOrdersView permissions and commands', () => {
|
||||
|
||||
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-dialog="编辑派送状态"] select').setValue('DELIVERED')
|
||||
await wrapper.find('[data-dialog="编辑派送状态"] select').setValue('DELIVERING')
|
||||
await wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: undefined })
|
||||
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: undefined })
|
||||
|
||||
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -86,7 +86,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('maps delivering separately from completion and blocks duplicate or conflicted submission', async () => {
|
||||
it('excludes manual completion and blocks duplicate or conflicted status submission', async () => {
|
||||
canPermission.mockReturnValue(true)
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
@@ -94,7 +94,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
|
||||
await flushPromises()
|
||||
const select = wrapper.find('[data-dialog="编辑派送状态"] select')
|
||||
expect(select.find('option[value="DELIVERING"]').text()).toBe('派送中')
|
||||
expect(select.find('option[value="DELIVERED"]').text()).toBe('派送完成')
|
||||
expect(select.find('option[value="DELIVERED"]').exists()).toBe(false)
|
||||
expect(select.find('option[value="WAIT_DELIVERY"]').exists()).toBe(false)
|
||||
await select.setValue('DELIVERING')
|
||||
await wrapper.find('[data-dialog="编辑派送状态"] textarea').setValue('123')
|
||||
@@ -114,7 +114,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
|
||||
expect(updateDeliveryOrderStatus).toHaveBeenCalledTimes(1)
|
||||
vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 8, allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] })
|
||||
await state.refreshStatus(); await flushPromises()
|
||||
expect(select.findAll('option').map(option => option.attributes('value'))).toEqual(['DELIVERED'])
|
||||
expect(select.findAll('option')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -192,37 +192,27 @@ describe('DeliveryOrdersView permissions and commands', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('selects completion through the real dialog and select, then retries a conflict with the refreshed version', async () => {
|
||||
it('hides status editing when completion is the only server action', async () => {
|
||||
canPermission.mockImplementation(code => code.endsWith(':status'))
|
||||
vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 7, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] })
|
||||
vi.mocked(updateDeliveryOrderStatus).mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '版本冲突' } } })
|
||||
const wrapper = mount(DeliveryOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, AppDialog: false, ElInput: false, ElSelect: false, ElOption: false } } })
|
||||
const body = new DOMWrapper(document.body)
|
||||
try {
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = body.find('[role="dialog"][aria-label="编辑派送状态"]')
|
||||
const save = () => dialog.findAll('button').find(button => button.text() === '保存状态')!
|
||||
expect(save().attributes('disabled')).toBeDefined()
|
||||
await dialog.find('.el-select__wrapper').trigger('click')
|
||||
await flushPromises()
|
||||
const options = body.findAll('.el-select-dropdown__item')
|
||||
expect(options.map(option => option.text())).toEqual(['派送完成'])
|
||||
await options[0]!.trigger('click')
|
||||
await dialog.find('textarea').setValue('已交付')
|
||||
await save().trigger('click'); await flushPromises()
|
||||
expect(updateDeliveryOrderStatus).toHaveBeenLastCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: '已交付' })
|
||||
expect(save().attributes('disabled')).toBeDefined()
|
||||
expect(dialog.text()).toContain('请刷新最新状态并重新确认')
|
||||
vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 8, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] })
|
||||
await dialog.findAll('button').find(button => button.text().includes('重新加载'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect((dialog.find('textarea').element as HTMLTextAreaElement).value).toBe('已交付')
|
||||
await save().trigger('click'); await flushPromises()
|
||||
expect(updateDeliveryOrderStatus).toHaveBeenLastCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 8, remark: '已交付' })
|
||||
expect(listDeliveryOrders).toHaveBeenCalledTimes(2)
|
||||
} finally { wrapper.unmount() }
|
||||
vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
expect(wrapper.find('[data-testid="data-table"]').text()).not.toContain('编辑状态')
|
||||
expect(getDeliveryOrder).not.toHaveBeenCalled()
|
||||
expect(updateDeliveryOrderStatus).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows the destination FBA warehouse that identifies each split delivery', async () => {
|
||||
vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, destinationWarehouseCode: 'ACY2', destinationWarehouseName: 'Amazon ACY2' }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
const table = defineComponent({
|
||||
props: { data: Array as PropType<object[]>, columns: Array as PropType<Array<{ key: string; formatter?: (row: object) => string }>> },
|
||||
setup(props) { return () => h('div', props.columns?.find(column => column.key === 'destinationWarehouse')?.formatter?.(props.data?.[0] || {}) || '') },
|
||||
})
|
||||
const wrapper = mount(DeliveryOrdersView, { global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, DataTable: table } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('ACY2 · Amazon ACY2')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders category counts from the list without per-row document requests', async () => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,7 +15,10 @@ describe('OutboundDetailView', () => {
|
||||
vi.mocked(getOutbound).mockReset().mockResolvedValue({
|
||||
outbound: { id: '1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
|
||||
pallets: [{ palletNo: 'IP-ACY2-20260907-597057-P001', inboundCode: 'NYEXX2609060063', destinationWarehouseCode: 'ONT8', destinationWarehouseName: 'Amazon ONT8', skuCode: 'SKU-001', packageCount: 20 }],
|
||||
delivery: { version: 2, allowedActions: ['START_DELIVERY'], id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
|
||||
deliveries: [
|
||||
{ version: 2, allowedActions: ['START_DELIVERY'], id: '2', customerId: 'customer-1', deliveryNo: 'DEL-ONT8', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', destinationWarehouseCode: 'ONT8', palletCount: 1, packageCount: 10, createdAt: '2026-09-01T10:00:00' },
|
||||
{ version: 2, allowedActions: ['START_DELIVERY'], id: '3', customerId: 'customer-1', deliveryNo: 'DEL-ACY2', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', destinationWarehouseCode: 'ACY2', palletCount: 1, packageCount: 10, createdAt: '2026-09-01T10:00:00' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +28,9 @@ describe('OutboundDetailView', () => {
|
||||
expect(getOutbound).toHaveBeenCalledWith('OUT/1')
|
||||
expect(wrapper.text()).toContain('OUT/1')
|
||||
expect(wrapper.text()).toContain('PC-1')
|
||||
expect(wrapper.text()).toContain('DEL-1')
|
||||
expect(wrapper.text()).toContain('DEL-ONT8')
|
||||
expect(wrapper.text()).toContain('DEL-ACY2')
|
||||
expect(wrapper.text()).toContain('ACY2')
|
||||
expect(wrapper.text()).toContain('出库托盘')
|
||||
expect(wrapper.text()).toContain('IP-ACY2-20260907-597057-P001')
|
||||
expect(wrapper.text()).toContain('NYEXX2609060063')
|
||||
@@ -40,7 +45,7 @@ describe('OutboundDetailView', () => {
|
||||
vi.mocked(getOutbound).mockResolvedValueOnce({
|
||||
outbound: { id: '1', outboundNo: 'OUT/1', status: 'OUTBOUNDED' },
|
||||
pallets: [{ palletNo: null, inboundCode: null, destinationWarehouseCode: null, destinationWarehouseName: null, skuCode: null, packageCount: null }],
|
||||
delivery: null,
|
||||
deliveries: [],
|
||||
})
|
||||
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -51,7 +56,7 @@ describe('OutboundDetailView', () => {
|
||||
vi.mocked(getOutbound).mockResolvedValueOnce({
|
||||
outbound: { id: '1', outboundNo: 'OUT/1', status: 'OUTBOUNDED' },
|
||||
pallets: [],
|
||||
delivery: null,
|
||||
deliveries: [],
|
||||
})
|
||||
const emptyWrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -74,7 +79,7 @@ describe('OutboundDetailView', () => {
|
||||
])('uses a matching warehouse name and never displays internal IDs (%s, %s)', async (deliveryWarehouseId, name, expected) => {
|
||||
vi.mocked(getOutbound).mockResolvedValueOnce({
|
||||
outbound: { id: '1', outboundNo: 'OUT/1', overseasWarehouseId: 'warehouse-internal-id' },
|
||||
delivery: { id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00', version: 1, allowedActions: [], overseasWarehouseId: deliveryWarehouseId, overseasWarehouseName: name },
|
||||
deliveries: [{ id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00', version: 1, allowedActions: [], overseasWarehouseId: deliveryWarehouseId, overseasWarehouseName: name }],
|
||||
})
|
||||
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -37,7 +37,7 @@ function show(value: unknown) { const text = String(value ?? '').trim(); return
|
||||
// A warehouse ID is only a join key; never render it as a warehouse name.
|
||||
const warehouseLabel = computed(() => {
|
||||
const outboundWarehouseId = detail.value?.outbound.overseasWarehouseId
|
||||
const delivery = detail.value?.delivery
|
||||
const delivery = detail.value?.deliveries?.find(item => item.overseasWarehouseId === outboundWarehouseId)
|
||||
return outboundWarehouseId && delivery?.overseasWarehouseId === outboundWarehouseId ? show(delivery.overseasWarehouseName) : '—'
|
||||
})
|
||||
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
|
||||
@@ -54,11 +54,11 @@ watch(outboundNo, () => { void load() }, { immediate: true })
|
||||
<template #header><div class="detail-heading"><el-button class="density-back" link aria-label="返回出库单列表" @click="back"><ArrowLeft :size="16" />返回</el-button><div class="detail-title"><div><h1>出库单详情</h1><p>出库单号 {{ outboundNo }}</p></div><StatusBadge v-if="detail" :status="statusLabel(detail.outbound.status)" /></div><el-button v-if="canCancel" type="danger" plain :loading="cancelling" @click="cancel"><XCircle :size="16" />取消出库单</el-button></div></template>
|
||||
<OperationErrorToast :details="mutationError" />
|
||||
<LoadErrorState v-if="loadError" title="出库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail"><section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions class="density-descriptions" :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="出库类型">{{ outboundTypeLabel(detail.outbound.outboundType) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ warehouseLabel }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section><section class="surface-panel detail-section"><h2>出库托盘</h2><DataTable class="outbound-pallet-table" aria-label="出库托盘" :data="detail.pallets || []" :columns="palletColumns" stripe column-settings-mode="hide" empty-text="暂无出库托盘" /></section><section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.delivery" description="暂无关联派送单" :image-size="56" /><el-descriptions class="density-descriptions" v-else :column="4" border><el-descriptions-item label="派送单号">{{ show(detail.delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ show(detail.delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(detail.delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(detail.delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(detail.delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(detail.delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(detail.delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="备注">{{ show(detail.delivery.remark) }}</el-descriptions-item></el-descriptions></section></template>
|
||||
<template v-else-if="detail"><section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions class="density-descriptions" :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="出库类型">{{ outboundTypeLabel(detail.outbound.outboundType) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ warehouseLabel }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section><section class="surface-panel detail-section"><h2>出库托盘</h2><DataTable class="outbound-pallet-table" aria-label="出库托盘" :data="detail.pallets || []" :columns="palletColumns" stripe column-settings-mode="hide" empty-text="暂无出库托盘" /></section><section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.deliveries?.length" description="暂无关联派送单" :image-size="56" /><div v-else class="delivery-list"><el-descriptions v-for="delivery in detail.deliveries" :key="delivery.deliveryNo" class="density-descriptions" :column="4" border><el-descriptions-item label="目的 FBA 仓">{{ show(delivery.destinationWarehouseCode || delivery.destinationWarehouseName) }}</el-descriptions-item><el-descriptions-item label="派送单号">{{ show(delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ statusLabel(delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ delivery.palletCount }}</el-descriptions-item><el-descriptions-item label="箱数">{{ delivery.packageCount }}</el-descriptions-item><el-descriptions-item label="备注" :span="2">{{ show(delivery.remark) }}</el-descriptions-item></el-descriptions></div></section></template>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>.detail-heading{display:flex;min-width:0;align-items:center;gap:var(--admin-space-5)}.detail-section h2{margin:0 0 var(--admin-space-5);font-size:var(--admin-font-section)}.detail-section+.detail-section{margin-top:var(--admin-space-5)}@media(max-width:1200px){.detail-section :deep(.el-descriptions__body .el-descriptions__table){table-layout:auto}}@media(max-width:720px){.detail-heading,.detail-title{align-items:flex-start;flex-direction:column}}
|
||||
<style scoped>.detail-heading{display:flex;min-width:0;align-items:center;gap:var(--admin-space-5)}.detail-section h2{margin:0 0 var(--admin-space-5);font-size:var(--admin-font-section)}.detail-section+.detail-section{margin-top:var(--admin-space-5)}.delivery-list{display:grid;gap:var(--admin-space-4)}@media(max-width:1200px){.detail-section :deep(.el-descriptions__body .el-descriptions__table){table-layout:auto}}@media(max-width:720px){.detail-heading,.detail-title{align-items:flex-start;flex-direction:column}}
|
||||
:deep(.detail-page-layout) { gap: 0; background: var(--admin-surface); border: 1px solid var(--cc-border); }
|
||||
:deep(.detail-page-layout__header) { padding: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-title {display:flex;min-width:0;flex:1;align-items:center; justify-content: flex-start;gap:var(--admin-space-5); }
|
||||
|
||||
Reference in New Issue
Block a user