Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into dev

This commit is contained in:
2026-09-01 10:18:48 +08:00
221 changed files with 6764 additions and 5701 deletions
@@ -4,9 +4,11 @@
| 页面/能力 | Method + Path | Params / Body(当前 adapter) | 权限与承载 |
|---|---|---|---|
| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 |
| 尾程转目的仓 | `PUT /platform/v1/last-leg/orders/destination` | body:`orderIds:string[],warehouseId,warehouseCode,warehouseName,remark?` | `...:last-mile-order:edit`;失败保留弹框,不提示成功 |
| 尾程转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert` | body:`orderId:string`,不传客户 | `...:inbound-order:create`;不接补托/PDA |
| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId`;货主只消费响应 `customerName`,空值显示 `—` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 |
| 尾程转目的仓 | `PUT /platform/v1/last-leg/orders/destination` | body:`orderIds:string[],warehouseId,warehouseCode,warehouseName,remark?`;目的仓只取国内 Warehouse 正式 `FBA + ACTIVE` 公共候选,候选切换时同步提交精确字符串 ID、编码和名称,不允许自由输入 | `...:last-mile-order:edit`;保留原尾程订单/入仓码多选范围与跨客户能力;候选或提交失败保留弹框并展示错误元数据,不提示成功 |
| 尾程转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert` | body:`orderId:string,customerId:string`,客户作用域精确取自所选尾程行;缺失时前端失败关闭 | `...:inbound-order:create`;A0404/A0409 保留 code/requestId/traceId;不接补托/PDA |
| 尾程整柜转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert-container` | body:`sourceContainerBookingId:string`;只调用一次柜级原子接口,不在前端循环逐票;返回一个计划数组,每个尾程订单/入仓码对应一个独立计划 | `...:inbound-order:create`;成功按接口返回计划数提示并刷新原待分配订单列表,不用当前订单页行数校验整柜总数;错误保留 code/requestId/traceId |
| 柜级计划进度/详情 | `GET /platform/v1/last-leg/orders/container-summaries`;`GET /container-summaries/{sourceContainerBookingId}` | summary:`sourceContainerBookingId,containerNo?,totalOrderCount,inboundCodeCount,missingInboundCodeCount,planCount,plannedInboundCodeCount,pendingPlanCount,plannedArrivalAt?,allPlansReady,blockingReason?`;list query:`page,size,containerNo?,createdFrom?,createdTo?`;detail:`summary,orders[]`,订单与计划 ID 均为字符串 | 独立“柜子”Tab 消费服务端全柜分页,默认按计划到仓升序且 null 最后;详情抽屉显示柜内全部订单、入仓码和独立计划状态;同一摘要也供按柜生成选择框,不从订单分页本地拼柜 |
| 入库列表 | `GET /platform/v1/last-leg/inbound-plans` | query:`page,size,planNo,inboundCode,status,warehouse,fbaWarehouseCode,appointmentStatus,dispatchStatus,createdFrom,createdTo`;平台跨客户 | `...:inbound-order:list`;`lineId/planId/orderId/customerId` 均为字符串 |
| 入库详情 | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | query:显式 `customerId`;path 编码 | 同 list;缺客户范围时页面明确报错;`sourceOrderId` 字符串化 |
| FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 |
@@ -14,7 +16,7 @@
| 排车列表/详情 | `GET /platform/v1/last-leg/dispatches`;`GET /dispatches/{dispatchNo}` | list query:`page,size,dispatchNo,vehiclePlate,status,fbaAppointmentNo`;不传客户 | `...:inbound-order:list`;Controller 受 `lastleg.dispatch-query-api.enabled` 控制 |
| 排车候选 | `GET /platform/v1/last-leg/dispatches/candidates` | query:`planIds?`(逗号分隔长 ID)、`orderNo?`,`fbaWarehouseCode?`,`sourceStage?`,`draftToken?` | 同 inbound list;仅发 `PLAN/RECEIVED` |
| 草稿/锁/预排 | `GET /dispatches/drafts`;`POST /drafts/containers`;`DELETE /drafts/{draftToken}/containers/{containerId}`;`POST /draft-locks/lock|unlock|release`;`POST /pre-arrange` | 车厢:`draftToken,capacity`;锁:`planPalletId,draftToken,draftContainerId?`;释放:`draftToken`;预排:`planPalletIds:string[],draftToken` | `...:inbound-order:smart-dispatch`;Controller 受 `lastleg.smart-dispatch-api.enabled` 控制;409 锁冲突显示真实错误;不调用编辑、取消、depart、PDA 或派送 |
| 库存汇总/余额 | `GET /platform/v1/last-leg/inventory/summary`;`GET /balances` | summary 强制 `page=1,size=200`;余额保留页面分页;支持 `skuCode,productName` 与仓库/入库/批次/库位筛选;不传客户 | `...:inventory:list`;只读,不调用 allocate/unallocate/pick/ship |
| 库存汇总/余额 | `GET /platform/v1/last-leg/inventory/summary`;`GET /balances` | summary 强制 `page=1,size=200`;余额保留页面分页;支持 `skuCode,productName` 与仓库/入库/批次/库位筛选;不传客户;列表/详情客户只显示 `customerName`,海外仓只显示 `overseasWarehouseName`,空值为 `—` | `...:inventory:list`;只读,不调用 allocate/unallocate/pick/ship;不以客户 ID、本地客户候选、国内来源仓或目的仓猜名称 |
| 库存位置/进销存 | `GET /inventory/balances/{balanceId}/positions`;`GET /inventory/ledger` | position:`palletNo?,batchNo?,inventoryStatus?`;ledger 含 `page,size,skuCode` 与业务单/仓库/时间筛选 | 同 inventory list;`balanceId` 编码;只展示后端事实 |
| 公共海外收货仓候选(国内 HTTP) | `GET /platform/v1/warehouse-admin` | `tenantId=0,page=1,size=200,capabilityCode=RECEIVING,warehouseType=OVERSEAS,status=ACTIVE` | 国内 Warehouse 是唯一仓库主数据源;托盘、库存和换标复用公共候选并保留字符串 ID,不请求海外仓库根路径 |
| 托盘列表/详情 | `GET /platform/v1/warehouse-admin/{warehouseId}/pallets`;`GET /pallets/{palletId}` | list:`page,size,keyword?,palletType?,status?`;无客户参数 | `...:pallet:list`;仓库/托盘长 ID 编码 |
@@ -4,6 +4,7 @@
## 需后端或产品继续确认
- **库存客户/海外仓快照**:正式余额 DTO 已返回 `customerName` 与 `overseasWarehouseName`,Admin 列表和详情只显示这两个服务端快照,空值为 `—`,不以 `customerId`、客户候选、国内来源仓、目的仓/FBA 仓或固定值补齐。当前测试数据“客户全为 — / 海外仓显示义乌仓”属于后端库存事实阻断:`LoadingCompletedConsumer#registerInTransit` 的客户名来自可能为空的跨区 `source.customerName`,并把国内装柜 `payload.warehouseName` 传入库存的 `overseasWarehouseName`;后续命中既有余额时 `InventoryCommandService#balance` 不会修订快照。后端需保证正式客户名称快照,并在实际海外收货仓冻结前保持海外仓为空、冻结后写入真实海外收货仓;前端不修复或猜测历史/错误事实。
- **换标原型字段**:列表 Summary 仍不含 `productNameZh/labelRequirement/relabelFile/budgetTotal/relabelCost`;本模块没有可用的换标文件上传端点。页面不伪造金额或文件名。创建 DTO 额外要求海外仓、截止时间、联系人、电话、货件号和新 SKU,这是完成真实创建的最小扩展。
- **托盘新增服务端权限**:前端新增按钮按 `platform:overseas-warehouse:pallet:create` 控制,但当前审计的 `PlatformWarehouseResourceController#createPallet` 未见对应 `@PreAuthorize`;需后端补齐或确认由网关等价强制。原型要求“托盘重量”,当前 Save DTO 无该字段,页面不伪造或发送。
- **托盘状态语义**:原型只给闲置/占用,DTO 还允许 `SCRAPPED`。是否允许 Admin 在普通编辑中直接改为报废仍需确认;不新增独立停用/报废动作。PUT 已按冻结契约携带详情 `version`,旧版本 `A0409`。
@@ -170,40 +170,6 @@ export async function releaseDispatchDraft(draftToken: string) {
return unwrapApiResponse(response)
}
export interface DispatchPdaPositionDto { id: number; balanceId?: number | null; availablePackages: number; allocatedPackages: number; pickedPackages: number }
export interface DispatchPdaItemDto {
dispatchItemId: number
palletNo?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
fbaWarehouseCode?: string | null
warehouseRouteLabel?: string | null
scheduledPackages: number
pickedPackages: number
positions: DispatchPdaPositionDto[]
}
export interface DispatchPdaTaskDto { id: number; taskNo: string; dispatchNo: string; vehiclePlate?: string | null; warehouseId?: string | null; status: string; items: DispatchPdaItemDto[] }
export interface DispatchPdaPushPayload { idempotencyKey: string; operatorId?: string; operatorName?: string; warehouseId?: string }
export interface DispatchPdaPickPayload { positionId: number; packages: number; productQuantity: number; idempotencyKey: string; remark?: string; warehouseId?: string }
export interface DispatchPdaCompletePayload { idempotencyKey: string; reason: string }
export async function pushDispatchPda(dispatchNo: string, payload: DispatchPdaPushPayload) {
const response = await overseasHttp.post<ApiEnvelope<DispatchPdaTaskDto>, ApiEnvelope<DispatchPdaTaskDto>>(`/pda/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/push`, payload)
return unwrapApiResponse(response)
}
export async function getDispatchPdaTask(dispatchNo: string) {
const response = await overseasHttp.get<ApiEnvelope<DispatchPdaTaskDto>, ApiEnvelope<DispatchPdaTaskDto>>(`/pda/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`)
return unwrapApiResponse(response)
}
export async function pickDispatchPda(dispatchNo: string, itemId: number, payload: DispatchPdaPickPayload) {
const response = await overseasHttp.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(`/pda/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/items/${itemId}/pick`, payload)
return unwrapApiResponse(response)
}
export async function completeDispatchPda(dispatchNo: string, payload: DispatchPdaCompletePayload) {
const response = await overseasHttp.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(`/pda/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/complete`, payload)
return unwrapApiResponse(response)
}
export interface TransferDeliveryResult { dispatchNo: string; outboundNo: string; outboundStatus: string; deliveryNo: string; deliveryStatus: string; palletCount: number; packageCount: number; replayed: boolean }
export interface TransferDeliveryPayload {
idempotencyKey: string
@@ -27,4 +27,11 @@ describe('overseas inventory API', () => {
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/190000000000000099/positions', { params: {} })
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger', { params: { page: 1, size: 20, skuCode: 'SKU-1' } })
})
it('passes the inbound code as a business filter and preserves the returned customer name', async () => {
const record = { id: '2093539779795795969', customerId: '2093539779795795970', customerName: '华远贸易', inboundCode: 'NYEXX2608300001', beforePackages: 0, deltaPackages: 2, afterPackages: 2 }
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [record], page: 1, size: 20, total: 1, pages: 1 } })
await expect(listInventoryLedger({ page: 1, size: 20, inboundCode: 'NYEXX2608300001', customerName: '华远贸易' })).resolves.toMatchObject({ records: [record] })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/ledger', { params: { page: 1, size: 20, inboundCode: 'NYEXX2608300001', customerName: '华远贸易' } })
})
})
@@ -68,6 +68,7 @@ export interface InventoryPositionDto {
export interface InventoryLedgerDto {
id: string
customerId: string
customerName?: string | null
overseasWarehouseId?: string | null
destinationWarehouseCode?: string | null
inboundOrderId?: string | null
@@ -142,6 +143,7 @@ export async function listInventoryPositions(
export interface InventoryLedgerQuery {
page: number
size: number
customerName?: string
overseasWarehouseId?: string
destinationWarehouseCode?: string
businessType?: string
@@ -1,15 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { convertLastMileOrderToInboundPlan, downloadLastMileOrders, getLastMileOrder, listLastMileOrders, setLastMileOrderDetained, setLastMileOrderPalletMergeBatch, setLastMileOrderPalletMergeRequired, transferLastMileOrders } from './lastMileOrders'
import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders } from './lastMileOrders'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
describe('last-mile order overseas API', () => {
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
beforeEach(() => {
vi.mocked(overseasHttp.get).mockReset()
vi.mocked(overseasHttp.put).mockReset()
vi.mocked(overseasHttp.post).mockReset()
})
it('loads all platform orders without a customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '0', pages: '0' } })
await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toEqual({ records: [], page: 2, size: 20, total: 0, pages: 0 })
const record = { id: '190000000000000099', customerId: '190000000000000001', sourceContainerBookingId: '190000000000000088', customerName: '华远贸易', orderNo: 'LMO-260827-0001' }
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [record], page: '2', size: '20', total: '1', pages: '1' } })
await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toEqual({ records: [record], page: 2, size: 20, total: 1, pages: 1 })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', {
params: { page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' },
})
@@ -79,34 +84,48 @@ describe('last-mile order overseas API', () => {
})
})
it('uses the order hold endpoint with the required operation remark', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: null })
await setLastMileOrderDetained('190000000000000099', { detained: true, remark: '等待质检确认' })
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/190000000000000099/detained', {
detained: true, remark: '等待质检确认',
})
})
it('marks an order for a PDA pallet-merge reminder', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: null })
await setLastMileOrderPalletMergeRequired('190000000000000099', { palletMergeRequired: true })
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/190000000000000099/pallet-merge-required', {
palletMergeRequired: true,
})
})
it('marks selected orders as one visible pallet-merge group', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: null })
await setLastMileOrderPalletMergeBatch(['11', '12'], true)
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/pallet-merge-required/batch', {
orderIds: ['11', '12'], palletMergeRequired: true,
})
})
it('uses the inbound-plan conversion endpoint without customerId', async () => {
it('uses the inbound-plan conversion endpoint with the exact selected customer scope', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} })
await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099' })
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert', { orderId: '190000000000000099' })
expect(JSON.stringify(vi.mocked(overseasHttp.post).mock.calls[0]?.[1])).not.toContain('customerId')
await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099', customerId: '190000000000000001' })
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert', {
orderId: '190000000000000099', customerId: '190000000000000001',
})
})
it('uses one atomic container request without looping individual conversions', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: [] })
await expect(convertLastMileContainerToInboundPlans({ sourceContainerBookingId: '2092873463568597001' })).resolves.toEqual([])
expect(overseasHttp.post).toHaveBeenCalledTimes(1)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert-container', {
sourceContainerBookingId: '2092873463568597001',
})
})
it('loads server-side container progress with pagination and filters', async () => {
const record = {
sourceContainerBookingId: '2092873463568597001', containerNo: 'CONT-15', totalOrderCount: 15,
inboundCodeCount: 13, missingInboundCodeCount: 2, planCount: 10, plannedInboundCodeCount: 10,
pendingPlanCount: 5, plannedArrivalAt: '2026-09-01T08:00:00', allPlansReady: false, blockingReason: '存在2票缺少入仓码',
}
vi.mocked(overseasHttp.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [record], page: '2', size: '10', total: '21', pages: '3' },
})
await expect(listContainerPlanProgress({
page: 2, size: 10, containerNo: 'CONT-15',
createdFrom: '2026-08-01T00:00:00', createdTo: '2026-08-31T23:59:59',
})).resolves.toEqual({ records: [record], page: 2, size: 10, total: 21, pages: 3 })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries', {
params: {
page: 2, size: 10, containerNo: 'CONT-15',
createdFrom: '2026-08-01T00:00:00', createdTo: '2026-08-31T23:59:59',
},
})
})
it('loads one container detail with the encoded stable long id', async () => {
const detail = { summary: { sourceContainerBookingId: '2092873463568597001' }, orders: [] }
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: detail })
await expect(getContainerPlanProgress('2092873463568597001/unsafe')).resolves.toEqual(detail)
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/2092873463568597001%2Funsafe')
})
})
@@ -1,5 +1,6 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { InboundPlanDetailDto } from './inboundPlans'
export interface OverseasPageResult<T> {
records: T[]
@@ -41,6 +42,7 @@ export interface LastMileOrderQuery {
export interface LastMileOrderSummaryDto {
id: string
customerId: string
sourceContainerBookingId?: string | null
orderNo: string
orderType?: string | null
orderTypeName?: string | null
@@ -153,15 +155,51 @@ export interface TransferDestinationPayload {
export interface ConvertInboundPlanPayload {
orderId: string
customerId: string
}
export interface SetLastMileOrderDetainedPayload {
detained: boolean
remark: string
export interface ContainerPlanProgressQuery {
page: number
size: number
containerNo?: string
createdFrom?: string
createdTo?: string
}
export interface SetLastMileOrderPalletMergePayload {
palletMergeRequired: boolean
export interface ContainerPlanProgressSummaryDto {
sourceContainerBookingId: string
containerNo: string | null
totalOrderCount: number
inboundCodeCount: number
missingInboundCodeCount: number
planCount: number
plannedInboundCodeCount: number
pendingPlanCount: number
plannedArrivalAt: string | null
allPlansReady: boolean
blockingReason: string | null
}
export interface ContainerPlanProgressItemDto {
orderId: string
customerId: string
orderNo: string | null
inboundCode: string | null
orderStatus: string
planId: string | null
planNo: string | null
planStatus: string | null
planValid: boolean
plannedArrivalAt: string | null
}
export interface ContainerPlanProgressDetailDto {
summary: ContainerPlanProgressSummaryDto
orders: ContainerPlanProgressItemDto[]
}
export interface ConvertContainerInboundPlanPayload {
sourceContainerBookingId: string
}
export interface LastMileOrderDetailDto {
@@ -231,23 +269,24 @@ export async function convertLastMileOrderToInboundPlan(payload: ConvertInboundP
return unwrapApiResponse(response)
}
export async function setLastMileOrderDetained(orderId: string, payload: SetLastMileOrderDetainedPayload) {
const response = await overseasHttp.put<ApiEnvelope<void>, ApiEnvelope<void>>(
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/detained`, payload,
export async function listContainerPlanProgress(query: ContainerPlanProgressQuery): Promise<OverseasPageResult<ContainerPlanProgressSummaryDto>> {
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>, ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>>(
'/platform/v1/last-leg/orders/container-summaries',
{ params: query },
)
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function getContainerPlanProgress(sourceContainerBookingId: string) {
const response = await overseasHttp.get<ApiEnvelope<ContainerPlanProgressDetailDto>, ApiEnvelope<ContainerPlanProgressDetailDto>>(
`/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(sourceContainerBookingId)}`,
)
return unwrapApiResponse(response)
}
export async function setLastMileOrderPalletMergeRequired(orderId: string, payload: SetLastMileOrderPalletMergePayload) {
const response = await overseasHttp.put<ApiEnvelope<void>, ApiEnvelope<void>>(
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/pallet-merge-required`, payload,
)
return unwrapApiResponse(response)
}
export async function setLastMileOrderPalletMergeBatch(orderIds: string[], palletMergeRequired: boolean) {
const response = await overseasHttp.put<ApiEnvelope<void>, ApiEnvelope<void>>(
'/platform/v1/last-leg/orders/pallet-merge-required/batch', { orderIds, palletMergeRequired },
export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload) {
const response = await overseasHttp.post<ApiEnvelope<InboundPlanDetailDto[]>, ApiEnvelope<InboundPlanDetailDto[]>>(
'/platform/v1/last-leg/inbound-plans/convert-container', payload,
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,26 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import OrderReferenceText from './OrderReferenceText.vue'
describe('OrderReferenceText', () => {
it.each([
['desktop-1920', '170px'],
['narrow', '88px'],
])('keeps the complete server order number in %s layouts', (_viewport, width) => {
const orderNo = 'LEGACY-LAST-MILE-ORDER-202608270000000001'
const wrapper = mount({
components: { OrderReferenceText },
template: `<div style="width:${width}"><OrderReferenceText value="${orderNo}" /></div>`,
})
const reference = wrapper.get('.order-reference-text')
expect(reference.text()).toBe(orderNo)
expect(reference.attributes('title')).toBe(orderNo)
})
it('renders the new short number in full and does not guess from another identifier', () => {
const wrapper = mount(OrderReferenceText, { props: { value: 'LMO-260827-0001' } })
expect(wrapper.text()).toBe('LMO-260827-0001')
expect(wrapper.attributes('title')).toBe('LMO-260827-0001')
})
})
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
value?: string | null
emptyText?: string
}>(), { value: null, emptyText: '—' })
const displayValue = computed(() => {
const value = String(props.value ?? '')
return value || props.emptyText
})
const fullTitle = computed(() => displayValue.value === props.emptyText ? undefined : displayValue.value)
</script>
<template>
<span class="order-reference-text" :title="fullTitle">{{ displayValue }}</span>
</template>
<style scoped>
.order-reference-text {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap;
}
</style>
@@ -22,7 +22,7 @@ defineEmits<{ back: [] }>()
<el-button :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />返回</el-button>
<div class="overseas-detail-title">
<h1>{{ title }}</h1>
<p>{{ referenceLabel }} <strong>{{ referenceValue }}</strong></p>
<p>{{ referenceLabel }} <strong><slot name="reference">{{ referenceValue }}</slot></strong></p>
</div>
<span class="overseas-detail-status" :class="{ 'is-live': !pending }" role="status">{{ statusText }}</span>
</header>
@@ -37,8 +37,8 @@ defineEmits<{ back: [] }>()
.overseas-detail-head { display: flex; min-height: 44px; align-items: center; gap: 12px; }
.overseas-detail-title { min-width: 0; flex: 1; }
.overseas-detail-title h1 { margin: 0; color: var(--cc-text); font-size: 20px; line-height: 1.35; }
.overseas-detail-title p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.overseas-detail-title strong { color: var(--cc-text); overflow-wrap: anywhere; }
.overseas-detail-title p { display: flex; min-width: 0; align-items: baseline; gap: 4px; margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.overseas-detail-title strong { min-width: 0; max-width: 100%; color: var(--cc-text); }
.overseas-detail-status { display: inline-flex; min-height: 26px; align-items: center; padding: 3px 10px; border: 1px solid #f3d19e; border-radius: 999px; background: #fdf6ec; color: #b88230; font-size: 12px; font-weight: 600; white-space: nowrap; }
.overseas-detail-status.is-live { border-color: var(--el-color-success-light-7); background: var(--el-color-success-light-9); color: var(--el-color-success-dark-2); }
</style>
@@ -11,6 +11,7 @@ import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
import { formatOverseasDateTime } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types'
import OrderReferenceText from './OrderReferenceText.vue'
type VehicleSpec = { value: string; label: string; capacity: number; maxWeightLb: number }
interface LoadingContainer { id: string; capacity: number; maxWeightLb: number; vehicleLabel: string; palletIds: string[] }
@@ -212,7 +213,7 @@ watch([warehouseTab, () => query.orderType], () => {
<header><div><h3>出库单列表 <span>({{ orderGroups.length }}单)</span></h3><div class="order-type-tabs"><button v-for="option in [{ value: '', label: '全部' }, ...orderTypeOptions]" :key="option.value || 'all'" type="button" :class="{ active: query.orderType === option.value }" @click="query.orderType = option.value">{{ option.label }}</button></div></div></header>
<div v-loading="loading" class="order-table-wrap">
<el-table :data="orderGroups" border height="520" highlight-current-row row-key="key" @current-change="activeOrderKey = $event?.key || ''">
<el-table-column label="订单号" min-width="150"><template #default="{ row }">{{ text(row.first.orderNo) }}</template></el-table-column>
<el-table-column label="订单号" min-width="170"><template #default="{ row }"><OrderReferenceText :value="text(row.first.orderNo)" /></template></el-table-column>
<el-table-column label="订单类型" width="100"><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column>
<el-table-column label="重量(lb)" width="100" align="right"><template #default="{ row }">{{ number(Number(row.first.volumeWeightKg || 0) * 2.2046226218, 0) }}LB</template></el-table-column>
<el-table-column label="托盘数量" width="90" align="right"><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column>
@@ -226,7 +227,7 @@ watch([warehouseTab, () => query.orderType], () => {
</section>
<section class="detail-pane" aria-label="入仓单托盘和箱号详情">
<header><h3>托盘明细 <span>{{ activePallets.length ? `(${activePallets.length}托)` : '' }}</span></h3><span>订单号:{{ text(activePallets[0]?.orderNo) }}</span></header>
<header><h3>托盘明细 <span>{{ activePallets.length ? `(${activePallets.length}托)` : '' }}</span></h3><span class="detail-order-reference">订单号:<OrderReferenceText :value="text(activePallets[0]?.orderNo)" /></span></header>
<div class="pallet-list">
<div v-for="item in activePallets" :key="palletId(item)" class="pallet-row" :class="{ assigned: containerFor(palletId(item)) }" draggable="true" @dragstart="dragStart($event, item)">
<GripVertical :size="16" class="drag-handle" />
@@ -247,7 +248,7 @@ watch([warehouseTab, () => query.orderType], () => {
<div v-for="(container, index) in containers" :key="container.id" class="loading-container" :class="{ 'loading-container--overweight': containerWeightLb(container) > container.maxWeightLb * 1.1 }" @dragover.prevent @drop.prevent="dropInto($event, container.id)">
<div class="container-head"><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><el-button text type="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)"><Trash2 :size="15" /></el-button></div>
<div class="container-body">
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet"><span><strong>{{ text(palletSnapshot(id)?.palletNo || id) }}</strong>{{ text(palletSnapshot(id)?.orderNo) }}</span><el-button text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button></div>
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet"><span><strong>{{ text(palletSnapshot(id)?.palletNo || '托盘号待补充') }}</strong><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span><el-button text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button></div>
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span>
</div>
</div>
@@ -277,6 +278,7 @@ watch([warehouseTab, () => query.orderType], () => {
.dispatch-workbench { display: grid; grid-template-columns: minmax(550px, 1.5fr) minmax(330px, .85fr) minmax(390px, 1fr); gap: 16px; margin-top: 16px; }
.order-pane, .detail-pane, .container-pane { min-width: 0; }
.order-pane header, .detail-pane header, .container-pane header { display: flex; min-height: 74px; align-items: center; justify-content: space-between; gap: 10px; }
.detail-order-reference { display: flex; min-width: 0; max-width: 240px; align-items: baseline; gap: 3px; }
h3, p { margin: 0; } h3 { font-size: 16px; } h3 span, header > span, p { color: var(--cc-muted); font-size: 12px; font-weight: 400; }
.order-type-tabs { display: flex; gap: 6px; margin-top: 10px; overflow-x: auto; }
.order-type-tabs button { flex: 0 0 auto; padding: 5px 12px; border-radius: 16px; font-size: 12px; }
@@ -298,7 +300,7 @@ h3, p { margin: 0; } h3 { font-size: 16px; } h3 span, header > span, p { color:
.container-head span { color: var(--cc-muted); font-size: 12px; }.container-head b { color: var(--el-color-primary); }
.container-body { min-height: 76px; padding: 8px; }.drop-hint { display: grid; min-height: 60px; place-items: center; color: var(--cc-muted); font-size: 12px; }
.loaded-pallet { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--cc-border); }
.loaded-pallet > span { display: flex; min-width: 0; flex-direction: column; color: var(--cc-muted); font-size: 12px; }.loaded-pallet strong { color: var(--cc-text); font-size: 13px; }
.loaded-pallet > span { display: flex; min-width: 0; flex: 1; flex-direction: column; color: var(--cc-muted); font-size: 12px; }.loaded-pallet strong { color: var(--cc-text); font-size: 13px; }
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 16px -20px -20px; padding: 14px 20px; border-top: 1px solid var(--cc-border); background: #fafbfc; }
.dialog-footer > span { color: var(--cc-muted); font-size: 12px; }
.vehicle-options { display: grid; gap: 14px; }
@@ -29,6 +29,8 @@ describe('overseas inventory presentation adapter', () => {
expect(row).toMatchObject({
id: '2089652067743272963',
customerId: '990018',
owner: 'Acceptance Customer',
warehouse: 'Acceptance US Warehouse',
sku: 'SKU-ACCEPT-1',
productName: '验收商品',
location: '—',
@@ -40,6 +42,30 @@ describe('overseas inventory presentation adapter', () => {
})
})
it('shows an unknown owner as a dash and never substitutes customerId', () => {
const row = toInventoryListItem({
id: '1', customerId: '2092618717861048321', customerName: null,
overseasWarehouseId: '3', inboundOrderId: '4', inboundCode: 'IN-1',
totalPackages: 1, availablePackages: 1, holdPackages: 0, allocatedPackages: 0,
pickedPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0,
})
expect(row.owner).toBe('—')
expect(row.owner).not.toBe(row.customerId)
})
it('uses only the formal overseas warehouse name snapshot', () => {
const row = toInventoryListItem({
id: '1', customerId: '2', customerName: '客户甲', overseasWarehouseId: '3',
overseasWarehouseCode: 'LAX', overseasWarehouseName: null, destinationWarehouseName: 'ONT8目的仓',
inboundOrderId: '4', inboundCode: 'IN-1', totalPackages: 1, availablePackages: 1,
holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, inTransitPackages: 0,
receivingPackages: 0, procurementPackages: 0,
})
expect(row.warehouse).toBe('—')
expect(row.warehouse).not.toBe('LAX')
expect(row.warehouse).not.toBe('ONT8目的仓')
})
it('falls back to the English product name only when the Chinese name is absent', () => {
expect(inventoryProductName({
id: '1', customerId: '2', overseasWarehouseId: '3', inboundOrderId: '4', inboundCode: 'IN-1',
@@ -30,7 +30,7 @@ export function toInventoryListItem(dto: InventoryBalanceDto): InventoryListItem
inboundOrderNo: text(dto.inboundOrderNo) === '—' ? '' : String(dto.inboundOrderNo),
inboundCode: text(dto.inboundCode) === '—' ? '' : String(dto.inboundCode),
owner: text(dto.customerName),
warehouse: text(dto.overseasWarehouseName || dto.overseasWarehouseCode),
warehouse: text(dto.overseasWarehouseName),
sku: text(dto.skuCode),
productName: inventoryProductName(dto),
location: '—',
@@ -23,6 +23,28 @@ describe('last-mile order presentation adapter', () => {
expect(row.rejectedAt).toBe('—')
})
it('shows the backend customerName and never guesses the owner from customerId', () => {
expect(toLastMileOrderListItem({
id: '1', customerId: '190000000000000001', customerName: '华远贸易', orderNo: 'LMO-260827-0001',
}).owner).toBe('华远贸易')
expect(toLastMileOrderListItem({
id: '2', customerId: '190000000000000002', customerName: null, orderNo: 'LMO-260827-0002',
}).owner).toBe('—')
})
it('preserves real metrics and distinguishes zero from unknown fields and times', () => {
const estimatedArrivalAt = '2026-08-27T12:30:00Z'
const row = toLastMileOrderListItem({
id: '3', customerId: '190000000000000003', orderNo: 'LMO-260827-0003',
palletCount: 3, packageCount: 0, volumeCbm: 12.75, productQuantity: null, volumeWeightKg: 88.6,
estimatedArrivalAt, actualArrivalAt: null, estimatedWarehouseAt: null,
})
expect(row).toMatchObject({
pallets: 3, cartons: 0, volume: 12.75, quantity: '—', volumeWeight: 88.6,
portEta: formatOverseasDateTime(estimatedArrivalAt), portAta: '—', warehouseEta: '—',
})
})
it.each([
['RELABEL_APPLICATION', '换标下单'],
['WAREHOUSE_TRANSFER', '仓储转运'],
@@ -27,8 +27,8 @@ function text(value: unknown) {
return normalized || '—'
}
function count(value: number | null | undefined) {
return Number.isFinite(value) ? Number(value) : 0
function metric(value: number | null | undefined): number | '—' {
return value != null && Number.isFinite(value) ? Number(value) : '—'
}
function yesNo(value: boolean | null | undefined) {
@@ -70,6 +70,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
return {
id: String(dto.id),
customerId: String(dto.customerId),
sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(),
orderNo: text(dto.orderNo),
warning: dto.abnormal ? '异常' : '—',
orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)),
@@ -83,12 +84,11 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
containerStatus: text(dto.containerStatus),
abnormal: yesNo(dto.abnormal),
held: yesNo(dto.detained),
detained: dto.detained === true,
pallets: count(dto.palletCount),
cartons: count(dto.packageCount),
volume: count(dto.volumeCbm),
quantity: count(dto.productQuantity),
volumeWeight: count(dto.volumeWeightKg),
pallets: metric(dto.palletCount),
cartons: metric(dto.packageCount),
volume: metric(dto.volumeCbm),
quantity: metric(dto.productQuantity),
volumeWeight: metric(dto.volumeWeightKg),
portEta: formatOverseasDateTime(dto.estimatedArrivalAt),
portAta: formatOverseasDateTime(dto.actualArrivalAt),
warehouseEta: formatOverseasDateTime(dto.estimatedWarehouseAt),
@@ -23,6 +23,7 @@ import {
type CustomerVisitRow,
} from '../api/customerVisits'
import { overseasWarehousePermissions as permission } from '../permissions'
import OrderReferenceText from '../components/OrderReferenceText.vue'
const access = useAccessPreviewStore()
const rows = ref<CustomerVisitRow[]>([])
@@ -188,7 +189,7 @@ onMounted(load)
<div class="inbound-options">
<label v-for="option in detail.inboundOrders" :key="option.inboundOrderId" class="inbound-option" :class="{ selected: form.selectedInboundOrderId === option.inboundOrderId }">
<el-radio v-model="form.selectedInboundOrderId" :value="option.inboundOrderId"><strong>{{ option.inboundCode }}</strong></el-radio>
<span>尾程单:{{ option.lastMileOrderNo }}</span><span>目的仓:{{ option.destinationWarehouseCode || option.destinationWarehouseName || '—' }}</span><span>箱数:{{ option.packageCount }}</span>
<span class="visit-order-reference">尾程单:<OrderReferenceText :value="option.lastMileOrderNo" /></span><span>目的仓:{{ option.destinationWarehouseCode || option.destinationWarehouseName || '—' }}</span><span>箱数:{{ option.packageCount }}</span>
</label>
</div>
</el-form-item>
@@ -216,6 +217,7 @@ onMounted(load)
.visit-order-summary div { display: grid; gap: 5px; } .visit-order-summary span { color: var(--cc-text-secondary); } .visit-order-summary strong { color: var(--cc-text-primary); }
.inbound-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; width: 100%; }
.inbound-option { display: grid; gap: 6px; padding: 12px; border: 1px solid var(--cc-border-color); border-radius: 8px; cursor: pointer; color: var(--cc-text-secondary); }
.visit-order-reference { display: flex; min-width: 0; align-items: baseline; gap: 3px; }
.inbound-option.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.file-picker { display: inline-flex; align-items: center; gap: 6px; padding: 8px 12px; color: var(--el-color-primary); border: 1px solid var(--el-color-primary); border-radius: 6px; cursor: pointer; }
.file-picker input { display: none; } .file-picker.disabled { opacity: .6; cursor: not-allowed; } .file-tip { margin-left: 12px; color: var(--cc-text-secondary); }
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, RotateCcw } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -18,22 +18,24 @@ import { listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryRetur
import { updateDeliveryOrderStatus } from '../api/customerVisits'
import type { ExceptionRelation } from '../api/exceptionOrders'
import { overseasWarehousePermissions as permission } from '../permissions'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
const router = useRouter()
const access = useAccessPreviewStore()
const rows = ref<DeliveryOrderRow[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]); const warehousesLoading = ref(false)
const filters = ref<QueryModel>({ documentNo: '', dispatchNo: '', waybillNo: '', warehouseId: '', status: '', vehiclePlate: '', driverName: '', deliveryMethod: '', plannedAt: [] })
const fields: QueryField[] = [
const fields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待派送', value: 'WAIT_DELIVERY' }, { label: '派送中', value: 'DELIVERING' }, { label: '派送完成', value: 'DELIVERED' }, { label: '已签收(历史)', value: 'SIGNED' }, { label: '已拒签', value: 'REJECTED' }, { label: '退货处理中', value: 'RETURN_PENDING' }, { label: '已取消', value: 'CANCELLED' }] },
{ key: 'documentNo', type: 'input', label: '派送单号', placeholder: '请输入派送单号' },
{ key: 'dispatchNo', type: 'input', label: '排车单号', placeholder: '请输入排车单号' },
{ key: 'waybillNo', type: 'input', label: '运单号', placeholder: '请输入运单号' },
{ key: 'warehouseId', type: 'input', label: '仓库', placeholder: '请输入仓库ID' },
{ key: 'warehouseId', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, loading: warehousesLoading.value } },
{ key: 'deliveryMethod', type: 'select', label: '派送方式', placeholder: '全部方式', options: [{ label: 'FBA自提', value: 'FBA_PICKUP' }, { label: '客户自提', value: 'CUSTOMER_PICKUP' }, { label: '卡派打托', value: 'TRUCK_PALLET' }, { label: '卡派地板', value: 'TRUCK_FLOOR' }, { label: '快递', value: 'EXPRESS' }, { label: '外配', value: 'OUTSOURCED' }, { label: '专车直送', value: 'DEDICATED_TRUCK' }, { label: '零担派送', value: 'LTL' }] },
{ key: 'vehiclePlate', type: 'input', label: '车牌号', placeholder: '请输入车牌号' },
{ key: 'driverName', type: 'input', label: '司机', placeholder: '请输入司机或车队' },
{ key: 'plannedAt', type: 'daterange', label: '预计派送时间' },
]
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'deliveryNo', prop: 'deliveryNo', label: '派送单号', minWidth: 180, fixed: 'left' },
@@ -71,7 +73,8 @@ function openStatus(row: DeliveryOrderRow) { statusRow.value = row; Object.assig
async function submitStatus() { if (!statusRow.value || !statusForm.status) return; statusSaving.value = true; try { const result = await updateDeliveryOrderStatus(statusRow.value.deliveryNo, statusRow.value.customerId, { status: statusForm.status, remark: statusForm.remark.trim() || undefined }); statusOpen.value = false; ElMessage.success(result.deliveryStatus === 'DELIVERED' ? '派送已完成,待回访记录已生成' : '派送状态已更新'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '派送状态更新失败').message) } finally { statusSaving.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
async function loadWarehouses() { if (!access.can(permission.warehouseList)) return; warehousesLoading.value = true; try { warehouses.value = (await listReceivingOverseasWarehouses()).records } catch { warehouses.value = [] } finally { warehousesLoading.value = false } }
onMounted(() => { void load(); void loadWarehouses() })
</script>
<template><AdminPage><ListPageLayout aria-label="派送单列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="派送单筛选" preference-key="overseas-delivery-orders-query" @search="search" @reset="reset" /></template><template v-if="error" #state><LoadErrorState title="派送单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-delivery-orders" row-key="id" empty-text="暂无派送单" @update:page="load()" @update:page-size="page = 1; load()"><template #actions="{ row }"><el-button size="small" type="primary" plain @click="open(row as DeliveryOrderRow)"><Eye :size="15" />详情</el-button><el-button v-if="access.can(permission.deliveryStatus) && row.status !== 'RETURN_PENDING'" size="small" plain @click="openStatus(row as DeliveryOrderRow)"><Pencil :size="15" />编辑状态</el-button><el-button v-if="access.can(permission.deliveryReturn) && !['RETURN_PENDING','CANCELLED'].includes(String(row.status))" size="small" type="warning" plain @click="openReturn(row as DeliveryOrderRow)"><RotateCcw :size="15" />标记退货</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></section></ListPageLayout></AdminPage><AppDialog v-model="statusOpen" title="编辑派送状态" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving" @confirm="submitStatus"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="statusRow?.deliveryNo" readonly /></el-form-item><el-form-item label="派送状态" required><el-select v-model="statusForm.status"><el-option label="待派送" value="WAIT_DELIVERY" /><el-option label="派送中" value="DELIVERING" /><el-option label="派送完成" value="DELIVERED" /><el-option label="已拒签" value="REJECTED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="备注"><el-input v-model="statusForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item><el-alert v-if="statusForm.status === 'DELIVERED'" title="保存为派送完成后,将自动生成一条待回访记录。" type="info" :closable="false" /></el-form></AppDialog><AppDialog v-model="returnOpen" title="标记退货" width="min(720px, calc(100vw - 28px))" confirm-text="确认转入退货" :loading="returnLoading" :confirm-loading="returnSaving" @confirm="submitReturn"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="returnRow?.deliveryNo" readonly /></el-form-item><el-form-item label="对应入仓单" required><el-select v-model="returnForm.inboundOrderId" placeholder="请选择该派送单对应的入仓单"><el-option v-for="option in returnOptions" :key="String(option.inboundOrderId)" :value="String(option.inboundOrderId)" :label="`${option.inboundCode} · ${option.lastMileOrderNo || '无尾程单号'}`" /></el-select></el-form-item><el-form-item label="退货原因" required><el-input v-model="returnForm.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item><div class="return-grid"><el-form-item label="退货去向" required><el-select v-model="returnForm.returnDirection"><el-option label="退回海外仓" value="OVERSEAS_WAREHOUSE" /><el-option label="退回客户" value="CUSTOMER" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="退回物流单号"><el-input v-model="returnForm.returnLogisticsNo" placeholder="可安排物流后补充" /></el-form-item><el-form-item label="箱数"><el-input-number v-model="returnForm.packageCount" :min="1" /></el-form-item><el-form-item label="SKU(可自由填写)"><el-input v-model="returnForm.skuCode" /></el-form-item></div><el-alert title="确认后会生成异常退货单,并将派送单标记为退货处理中。" type="warning" :closable="false" /></el-form></AppDialog></template>
@@ -1,12 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { completeDispatchPda, getDispatch, getDispatchPdaTask, pickDispatchPda, pushDispatchPda, type DispatchItemDto, type DispatchPdaItemDto, type DispatchPdaTaskDto, type DispatchSummaryDto } from '../api/dispatches'
import { getDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime } from '../lastMilePresentation'
@@ -26,13 +25,6 @@ const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
const detail = ref<DispatchSummaryDto | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const pdaTask = ref<DispatchPdaTaskDto | null>(null)
const pdaLoading = ref(false)
const pdaMutating = ref(false)
const pdaError = ref<ApiErrorDetails | null>(null)
const pdaPushKey = ref('')
const pdaReason = ref('')
const pickForm = ref({ itemId: 0, positionId: 0, packages: 1, productQuantity: 1, idempotencyKey: '' })
const dispatchStatusLabels: Record<string, string> = {
PENDING_DEPARTURE: '待发车',
@@ -134,40 +126,6 @@ async function load() {
}
function back() { void router.push({ name: 'overseas-dispatches' }) }
function makeKey(prefix: string) { return `${prefix}-${dispatchNo.value}-${Date.now()}-${Math.random().toString(16).slice(2)}` }
function selectPdaItem(item: DispatchPdaItemDto) {
const position = item.positions?.find((value) => value.availablePackages > 0) || item.positions?.[0]
pickForm.value = { itemId: item.dispatchItemId, positionId: Number(position?.id || 0), packages: 1, productQuantity: 1, idempotencyKey: makeKey('web-pick') }
}
function selectPdaRow(item: unknown) { selectPdaItem(item as DispatchPdaItemDto) }
async function loadPdaTask() {
if (!dispatchNo.value) return
pdaLoading.value = true; pdaError.value = null
try { pdaTask.value = await getDispatchPdaTask(dispatchNo.value); if (!pickForm.value.itemId && pdaTask.value.items?.[0]) selectPdaItem(pdaTask.value.items[0]) }
catch (error) { pdaError.value = getApiErrorDetails(error, 'PDA任务加载失败,请先启动拣货任务') }
finally { pdaLoading.value = false }
}
async function startPda() {
pdaMutating.value = true; pdaError.value = null
try { pdaTask.value = await pushDispatchPda(dispatchNo.value, { idempotencyKey: pdaPushKey.value.trim() || makeKey('web-push') }); pdaPushKey.value = ''; if (pdaTask.value.items?.[0]) selectPdaItem(pdaTask.value.items[0]); ElMessage.success('PDA拣货任务已启动') }
catch (error) { pdaError.value = getApiErrorDetails(error, '启动PDA任务失败') }
finally { pdaMutating.value = false }
}
async function submitPick() {
const form = pickForm.value
if (!form.itemId || !form.positionId || form.packages < 1) { ElMessage.warning('请选择拣货明细和有效库位、数量'); return }
pdaMutating.value = true; pdaError.value = null
try { await pickDispatchPda(dispatchNo.value, form.itemId, { positionId: form.positionId, packages: form.packages, productQuantity: form.productQuantity || form.packages, idempotencyKey: form.idempotencyKey.trim() || makeKey('web-pick') }); ElMessage.success('拣货成功,库存已扣减'); await loadPdaTask() }
catch (error) { pdaError.value = getApiErrorDetails(error, '拣货失败,请检查库位库存') }
finally { pdaMutating.value = false }
}
async function completePda() {
pdaMutating.value = true; pdaError.value = null
try { await completeDispatchPda(dispatchNo.value, { idempotencyKey: makeKey('web-complete'), reason: pdaReason.value.trim() || '正常完成拣货' }); ElMessage.success('拣货任务已完成'); await loadPdaTask(); await load() }
catch (error) { pdaError.value = getApiErrorDetails(error, '完成拣货失败') }
finally { pdaMutating.value = false }
}
onMounted(load)
</script>
@@ -181,20 +139,6 @@ onMounted(load)
<section class="detail-section document-section" aria-labelledby="dispatch-document-heading"><h2 id="dispatch-document-heading">单据信息</h2><dl class="detail-fields"><div v-for="field in documentFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
</div>
<section class="detail-section order-section" aria-labelledby="dispatch-orders-heading"><h2 id="dispatch-orders-heading">关联订单明细</h2><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="orderNo" empty-text="暂无关联订单明细" /></section>
<section class="detail-section pda-section" aria-labelledby="dispatch-pda-heading">
<div class="pda-header"><h2 id="dispatch-pda-heading">PDA拣货与库存扣减</h2><div class="pda-actions"><el-input v-model="pdaPushKey" placeholder="启动幂等键(可选)" clearable /><el-button type="primary" :loading="pdaMutating" @click="startPda">启动拣货</el-button><el-button :loading="pdaLoading" @click="loadPdaTask">刷新任务</el-button></div></div>
<el-alert v-if="pdaError" :title="pdaError.message" type="error" :closable="false" class="pda-alert" />
<div v-if="pdaTask" v-loading="pdaLoading" class="pda-body">
<div class="pda-meta">任务号:{{ pdaTask.taskNo }} 状态:<strong>{{ dispatchStatus(pdaTask.status) }}</strong> 仓库:{{ text(pdaTask.warehouseId) }}</div>
<el-table :data="pdaTask.items || []" size="small" border @row-click="selectPdaItem">
<el-table-column label="托盘" prop="palletNo" min-width="140" /><el-table-column label="转仓提示" min-width="150"><template #default="{ row }"><el-tag v-if="row.warehouseRouteLabel" type="warning" effect="plain">{{ row.warehouseRouteLabel }}</el-tag><span v-else>{{ text(row.fbaWarehouseCode || row.destinationWarehouseCode) }}</span></template></el-table-column><el-table-column label="计划箱数" prop="scheduledPackages" width="100" /><el-table-column label="已拣箱数" prop="pickedPackages" width="100" />
<el-table-column label="库位(可用)" min-width="220"><template #default="{ row }"><el-button v-for="position in row.positions || []" :key="position.id" link type="primary" @click.stop="selectPdaRow(row); pickForm.positionId = Number(position.id)">{{ position.id }}({{ position.availablePackages }})</el-button></template></el-table-column>
</el-table>
<div class="pick-form"><span class="pick-label">拣货明细:{{ pickForm.itemId || '未选择' }}</span><el-input-number v-model="pickForm.positionId" :min="1" controls-position="right" placeholder="库位ID" /><el-input-number v-model="pickForm.packages" :min="1" controls-position="right" placeholder="箱数" @change="(value) => { pickForm.productQuantity = Number(value || 1) }" /><el-input v-model="pickForm.idempotencyKey" placeholder="拣货幂等键" /><el-button type="success" :loading="pdaMutating" @click="submitPick">拣货并扣减库存</el-button></div>
<div class="pda-complete"><el-input v-model="pdaReason" placeholder="完成原因(可选)" /><el-button type="warning" :loading="pdaMutating" @click="completePda">完成拣货</el-button></div>
</div>
<el-empty v-else description="尚未加载PDA任务,请点击启动拣货" />
</section>
</div>
</OverseasDetailShell>
</template>
@@ -214,22 +158,10 @@ onMounted(load)
.detail-fields dd { margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; }
.order-section { overflow: hidden; border: 1px solid var(--cc-border); border-top: 0; background: #fff; }
.order-section h2 { margin-right: 4px; margin-left: 4px; }
.pda-section { border: 1px solid var(--cc-border); border-top: 0; background: #fff; }
.pda-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.pda-header h2 { margin-bottom: 0; }
.pda-actions, .pick-form, .pda-complete { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pda-actions :deep(.el-input) { width: 230px; }
.pda-alert { margin: 12px 0; }
.pda-meta { margin: 12px 0; color: var(--cc-muted); }
.pick-form { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--cc-border); }
.pick-form :deep(.el-input), .pda-complete :deep(.el-input) { width: 220px; }
.pick-label { color: var(--cc-muted); }
.pda-complete { justify-content: flex-end; margin-top: 12px; }
@media (max-width: 960px) {
.summary-grid { grid-template-columns: 1fr; }
.summary-grid > .detail-section, .summary-grid > .detail-section:nth-child(2) { border-right: 0; }
.document-section { grid-column: auto; }
.detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); }
.pda-actions { width: 100%; }
}
</style>
@@ -117,7 +117,7 @@ function toListItem(dto: DispatchSummaryDto): DispatchListItem {
carrierName: text(dto.carrierName),
orderNos: text(dto.orderNos),
route: text(dto.route),
overseasWarehouse: text(dto.overseasWarehouseName || dto.overseasWarehouseId),
overseasWarehouse: text(dto.overseasWarehouseName || '仓库名称待补充'),
departAt: formatOverseasDateTime(dto.departAt),
fbaAppointment: appointmentLabel(dto.fbaAppointmentNo, dto.fbaAppointmentStatus),
vehicleAppointment: text(dto.vehicleAppointmentStatus),
@@ -15,6 +15,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, type ExceptionOrderRow, type ExceptionRelation } from '../api/exceptionOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
const rows = ref<ExceptionOrderRow[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
const access = useAccessPreviewStore()
@@ -73,7 +74,7 @@ onMounted(load)
<template v-if="error" #state><LoadErrorState title="异常订单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-exception-orders" row-key="id" empty-text="暂无异常订单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.exceptionOrderCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增异常订单</el-button></template>
<template #related="{ row }"><div class="related-orders"><span v-if="row.lastMileOrderNo">尾程 {{ row.lastMileOrderNo }}</span><span v-if="row.deliveryNo">派送 {{ row.deliveryNo }}</span><span v-if="row.inboundCode">入仓 {{ row.inboundCode }}</span><span v-if="row.outboundNo">出库 {{ row.outboundNo }}</span></div></template>
<template #related="{ row }"><div class="related-orders"><span v-if="row.lastMileOrderNo" class="related-order-reference">尾程 <OrderReferenceText :value="String(row.lastMileOrderNo)" /></span><span v-if="row.deliveryNo">派送 {{ row.deliveryNo }}</span><span v-if="row.inboundCode">入仓 {{ row.inboundCode }}</span><span v-if="row.outboundNo">出库 {{ row.outboundNo }}</span></div></template>
<template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status)" /></template><template #pagination-left>共 {{ total }} 条异常订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
@@ -87,13 +88,13 @@ onMounted(load)
<el-form-item label="箱数"><el-input-number v-model="form.packageCount" :min="1" :max="relation?.availablePackages || 999999" /></el-form-item>
<el-form-item label="SKU(可自由填写)"><el-input v-model="form.skuCode" maxlength="128" /></el-form-item>
</div><el-form-item label="异常原因" required><el-input v-model="form.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item>
<el-alert v-if="relation" :title="`已关联:${relation.lastMileOrderNo || '无尾程单'} / ${relation.inboundCode || '无入仓单'},客户 ${relation.customerName || relation.customerId}`" type="success" :closable="false" />
<el-alert v-if="relation" type="success" :closable="false"><template #title><span class="relation-alert-title">已关联:<OrderReferenceText :value="relation.lastMileOrderNo || '无尾程单'" /> / {{ relation.inboundCode || '无入仓单' }},客户 {{ relation.customerName || '客户信息待补充' }}</span></template></el-alert>
<el-alert v-else-if="relationError" :title="relationError" type="error" :closable="false" />
</el-form>
</AppDialog>
</template>
<style scoped>
.related-orders { display: grid; gap: 2px; font-size: 12px; }.create-form { margin-top: 18px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
.related-orders { display: grid; gap: 2px; font-size: 12px; }.related-order-reference,.relation-alert-title { display: flex; min-width: 0; align-items: baseline; gap: 3px; }.create-form { margin-top: 18px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
@media (max-width: 720px) { .form-grid { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,70 @@
import ElementPlus from 'element-plus'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '../api/inventory'
import InventoryDetailView from './InventoryDetailView.vue'
import InventoryView from './InventoryView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => false }) }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() }))
vi.mock('../api/inventory', () => ({
listInventoryBalances: vi.fn(), getInventorySummary: vi.fn(),
listInventoryPositions: vi.fn(), listInventoryLedger: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value),
}
}
const customerId = '2092618717861048321'
const balance = {
id: '2092873463568596994', customerId, customerName: '华远贸易', overseasWarehouseId: '200',
overseasWarehouseName: '洛杉矶仓', inboundOrderId: '300', inboundOrderNo: 'LIP-1', inboundCode: 'IN-1',
destinationWarehouseName: 'ONT8目的仓',
skuCode: 'SKU-1', productNameZh: '验收商品', totalPackages: 10, availablePackages: 10,
holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, inTransitPackages: 0,
receivingPackages: 0, procurementPackages: 0, updatedAt: '2026-08-28T10:00:00',
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('inventory customer-name snapshots', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
vi.mocked(listInventoryBalances).mockReset().mockResolvedValue({ records: [balance], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(getInventorySummary).mockReset().mockResolvedValue({ inboundOrderCount: 1, palletCount: 1, totalPackages: 10, availablePackages: 10, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0 })
vi.mocked(listInventoryPositions).mockReset().mockResolvedValue([])
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
})
it('renders the formal server customerName in the inventory list', async () => {
const wrapper = mount(InventoryView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('华远贸易')
expect(wrapper.text()).toContain('洛杉矶仓')
expect(wrapper.text()).not.toContain('ONT8目的仓')
expect(wrapper.text()).not.toContain(customerId)
})
it('renders the same server snapshot in inventory detail', async () => {
routing.route.params = { inventoryId: balance.id }
routing.route.query = { overseasWarehouseId: balance.overseasWarehouseId, inboundOrderNo: balance.inboundOrderNo, inboundCode: balance.inboundCode, skuCode: balance.skuCode }
const wrapper = mount(InventoryDetailView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('华远贸易')
expect(wrapper.text()).toContain('洛杉矶仓')
expect(wrapper.text()).not.toContain('ONT8目的仓')
expect(wrapper.text()).not.toContain(customerId)
})
})
@@ -4,34 +4,42 @@ import { ArrowLeft, Download } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { downloadInventoryLedger, listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import type { InventoryTransactionListItem } from '../types'; import { overseasWarehousePermissions as permission } from '../permissions'; import { useAccessPreviewStore } from '@/core/access/accessPreview'; import { saveCsv } from '@/modules/overseas-master-data/downloadCsv'
import InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue'
import { saveCsv } from '@/shared/downloadCsv'
import { downloadInventoryLedger, listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import { overseasWarehousePermissions as permission } from '../permissions'; import type { InventoryTransactionListItem } from '../types'
const route = useRoute(); const router = useRouter(); const rows = ref<InventoryTransactionListItem[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const access = useAccessPreviewStore(); const exportLoading = ref(false)
const filters = ref<QueryModel>({ occurredDate: [], warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundOrderNo: '', outboundOrderNo: '' })
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore(); const rows = ref<InventoryTransactionListItem[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const exportLoading = ref(false)
const relationshipDialogOpen = ref(false); const relationshipDialogInboundCode = ref('')
const filters = ref<QueryModel>({ occurredDate: [], customerName: '', warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundCode: '', inboundOrderNo: '', outboundOrderNo: '' })
const fields = computed<QueryField[]>(() => [
{ key: 'occurredDate', type: 'daterange', label: '发生日期', width: 260 },
{ key: 'customerName', type: 'input', label: '客户名称', placeholder: '请输入客户名称', width: 190 },
{ key: 'warehouse', type: 'input', label: '目的仓代码', placeholder: '请输入目的仓代码', width: 190 }, { key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入 SKU 编码', width: 170 },
{ key: 'businessType', type: 'input', label: '业务类型', placeholder: '请输入业务类型代码', width: 170 }, { key: 'businessOrderNo', type: 'input', label: '业务订单号', placeholder: '请输入业务订单号', width: 180 },
{ key: 'inboundOrderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号', width: 180 }, { key: 'outboundOrderNo', type: 'input', label: '出库单号', placeholder: '只读关联筛选', width: 180 },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 190 }, { key: 'inboundOrderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号', width: 180 }, { key: 'outboundOrderNo', type: 'input', label: '出库单号', placeholder: '只读关联筛选', width: 180 },
])
const columns: DataTableColumn[] = [
{ key: 'occurredAt', prop: 'occurredAt', label: '发生时间', minWidth: 160 }, { key: 'owner', prop: 'owner', label: '客户', minWidth: 160 }, { key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 180 },
{ key: 'actions', label: '操作', width: 108, fixed: 'left', slotName: 'actions' },
{ key: 'occurredAt', prop: 'occurredAt', label: '发生时间', minWidth: 160 }, { key: 'owner', prop: 'owner', label: '客户', minWidth: 160 }, { key: 'warehouse', prop: 'warehouse', label: '目的仓编码', minWidth: 180 },
{ key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 }, { key: 'businessType', prop: 'businessType', label: '业务类型', width: 120 },
{ key: 'businessOrderNo', prop: 'businessOrderNo', label: '业务订单号', minWidth: 170 }, { key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 160 }, { key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
{ key: 'businessOrderNo', prop: 'businessOrderNo', label: '业务订单号', minWidth: 170 }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 }, { key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 160 }, { key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' }, { key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' }, { key: 'balance', prop: 'balance', label: '结存', width: 100, align: 'right' },
{ key: 'operator', prop: 'operator', label: '操作人', minWidth: 120 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
]
function text(value: unknown) { return String(value ?? '').trim() || '—' }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; loading.value = true; loadError.value = null; try { const result = await listInventoryLedger({ page: page.value, size: pageSize.value, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), owner: text(item.customerId), warehouse: text(item.destinationWarehouseCode), sku: text(item.skuCode), productName: '—', businessType: text(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: text(item.operatorId), remark: text(item.remark) })); total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '进销存记录加载失败,请重试') } finally { loading.value = false } }
function openRelationship(row: InventoryTransactionListItem) { if (row.inboundCode === '—') return; relationshipDialogInboundCode.value = row.inboundCode; relationshipDialogOpen.value = true }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; loading.value = true; loadError.value = null; try { const result = await listInventoryLedger({ page: page.value, size: pageSize.value, customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), owner: text(item.customerName || '客户信息待补充'), warehouse: text(item.destinationWarehouseCode), sku: text(item.skuCode), productName: '—', businessType: text(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundCode: text(item.inboundCode), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: item.operatorId ? '操作人名称待补充' : '—', remark: text(item.remark) })); total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '进销存记录加载失败,请重试') } finally { loading.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }; function reset(model: QueryModel) { page.value = 1; void load(model) }; function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load)
async function exportLedger() { exportLoading.value = true; try { const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; saveCsv(await downloadInventoryLedger({ destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }) as Blob, '进销存记录.csv') } catch { ElMessage.error('进销存导出失败,请重试') } finally { exportLoading.value = false } }
async function exportLedger() { exportLoading.value = true; try { const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; saveCsv(await downloadInventoryLedger({ customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }) as Blob, '进销存记录.csv') } catch { ElMessage.error('进销存导出失败,请重试') } finally { exportLoading.value = false } }
</script>
<template><AdminPage><ListPageLayout aria-label="进销存记录列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template v-else #state><p class="read-only-boundary">出库相关字段仅展示既有库存流水事实;本页面不提供出库单跳转、库存扣减或其他离仓操作。当前流水 DTO 未提供产品名称与海外仓名称,对应列显示空值。</p></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory-transactions" row-key="id" empty-text="暂无进销存记录" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport)" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory-transactions" row-key="id" empty-text="暂无进销存记录" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport)" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><el-button link type="primary" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<InboundCodeRelationshipDialog v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
</ListPageLayout></AdminPage></template>
<style scoped>.read-only-boundary{margin:8px 2px 0;color:var(--cc-muted);font-size:12px;line-height:1.5}</style>
@@ -11,7 +11,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { downloadInventoryBalances, getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { saveCsv } from '@/modules/overseas-master-data/downloadCsv'
import { saveCsv } from '@/shared/downloadCsv'
import { ElMessage } from 'element-plus'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { toInventoryListItem } from '../inventoryPresentation'
@@ -14,6 +14,7 @@ import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto }
import { formatOverseasDateTime, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import OrderReferenceText from '../components/OrderReferenceText.vue'
interface DetailField { label: string; value: string | number }
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
@@ -38,7 +39,7 @@ function text(value: unknown) { return String(value ?? '').trim() || '—' }
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
const referenceValue = computed(() => text(detail.value?.order.orderNo || orderId.value))
const referenceValue = computed(() => text(detail.value?.order.orderNo || '订单编码待补充'))
const orderFields = computed<DetailField[]>(() => {
const order = detail.value?.order
return [
@@ -150,6 +151,7 @@ onMounted(load)
<template>
<OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="referenceValue" back-label="返回尾程订单" domain="尾程订单详情" :pending="false" :status-text="detail ? lastMileStatusLabel(detail.order.currentStatus) : '加载中'" @back="back">
<template #reference><OrderReferenceText :value="referenceValue" /></template>
<OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="detail-surface" :aria-busy="loading">
@@ -0,0 +1,282 @@
import ElementPlus from 'element-plus'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import DataTable from '@/shared/components/DataTable.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import {
convertLastMileContainerToInboundPlans,
convertLastMileOrderToInboundPlan,
getContainerPlanProgress,
listContainerPlanProgress,
listLastMileOrders,
transferLastMileOrders,
} from '../api/lastMileOrders'
import LastMileOrdersView from './LastMileOrdersView.vue'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('../api/lastMileOrders', () => ({
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
convertLastMileOrderToInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(),
downloadLastMileOrders: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value),
}
}
const bookingId = '2092873463568597001'
const customerId = '2092618717861048321'
const summary = {
sourceContainerBookingId: bookingId, containerNo: 'CONT-15', totalOrderCount: 15,
inboundCodeCount: 15, missingInboundCodeCount: 0, planCount: 10, plannedInboundCodeCount: 10,
pendingPlanCount: 5, plannedArrivalAt: '2026-09-01T08:00:00', allPlansReady: false,
blockingReason: '存在5票尚未生成有效入库计划',
}
const fbaWarehouse = {
id: '9007199254740993', tenantId: '0', warehouseCode: 'ONT8', warehouseName: 'Ontario FBA',
warehouseType: 'FBA', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null,
contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null,
primaryAddress: {
id: '9007199254740994', tenantId: '0', warehouseId: '9007199254740993', addressType: 'OPERATING',
countryCode: 'US', stateProvince: 'CA', city: 'Ontario', district: null, postalCode: '91761',
addressLine1: 'FBA address', addressLine2: null, latitude: null, longitude: null,
},
addressSummary: 'CA Ontario',
}
function selectedRow(overrides: Record<string, unknown> = {}) {
return { id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001', ...overrides }
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
async function mountView() {
const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
return wrapper
}
async function openPending() {
const wrapper = await mountView()
const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')
await pendingTab!.trigger('click')
await flushPromises()
return wrapper
}
async function selectRows(wrapper: Awaited<ReturnType<typeof mountView>>, rows: object[]) {
wrapper.findComponent(DataTable).vm.$emit('selection-change', rows)
await wrapper.vm.$nextTick()
}
function button(wrapper: Awaited<ReturnType<typeof mountView>>, text: string) {
return wrapper.findAll('button').find((item) => item.text().includes(text))
}
async function openContainerDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
await button(wrapper, '按柜批量生成')!.trigger('click')
await flushPromises()
}
async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
const containerSelect = selects[selects.length - 1]
expect(containerSelect).toBeDefined()
containerSelect.vm.$emit('update:modelValue', bookingId)
await wrapper.vm.$nextTick()
}
describe('LastMileOrdersView pending order and container conversion', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.mocked(listLastMileOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
vi.mocked(listContainerPlanProgress).mockReset().mockResolvedValue({ records: [summary], page: 1, size: 50, total: 1, pages: 1 })
vi.mocked(getContainerPlanProgress).mockReset().mockResolvedValue({
summary,
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
})
vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({})
vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([{ planNo: 'LIP-1' }, { planNo: 'LIP-2' }] as never)
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([fbaWarehouse] as never)
vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2)
})
it('keeps the original pending order table and does not render a view switch or container table', async () => {
const wrapper = await openPending()
expect(wrapper.text()).toContain('尾程单号')
expect(wrapper.text()).toContain('海外仓')
expect(wrapper.text()).toContain('转仓')
expect(wrapper.text()).toContain('生成入库计划')
expect(wrapper.text()).toContain('按柜批量生成')
expect(wrapper.text()).not.toContain('按订单')
expect(wrapper.text()).not.toContain('待分配整柜')
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: 'PENDING_ASSIGNMENT' }))
expect(listContainerPlanProgress).not.toHaveBeenCalled()
})
it('keeps single-order conversion isolated with long order and customer ids', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '生成入库计划')!.trigger('click')
await flushPromises()
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledTimes(1)
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledWith({ orderId: '2092873463568596994', customerId })
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
expect(listContainerPlanProgress).not.toHaveBeenCalled()
})
it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => {
const wrapper = await openPending()
await openContainerDialog(wrapper)
expect(listContainerPlanProgress).toHaveBeenCalledTimes(1)
expect(listContainerPlanProgress).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined })
expect(document.body.textContent).toContain('选择待分配柜')
expect(document.body.textContent).toContain('将为所选柜内所有待分配尾程订单分别生成独立入库计划')
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
})
it('selects one cabinet and sends one long-id convert-container request without using current-page row counts', async () => {
const wrapper = await openPending()
await openContainerDialog(wrapper)
await chooseContainer(wrapper)
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement
expect(confirm.disabled).toBe(false)
confirm.click()
await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId })
expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('已按柜生成 2 个独立入库计划')
expect(listLastMileOrders).toHaveBeenCalledTimes(3)
})
it.each(['A0404', 'A0409'])('keeps %s request evidence visible after cabinet conversion failure', async (code) => {
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError(
'柜级生成失败', code, 200, undefined, `request-${code}`, `trace-${code}`,
))
const wrapper = await openPending()
await openContainerDialog(wrapper)
await chooseContainer(wrapper)
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement
confirm.click()
await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain(code)
expect(wrapper.text()).toContain(`request-${code}`)
expect(wrapper.text()).toContain(`trace-${code}`)
})
it('loads the cabinet tab from server progress, shows the total and preserves server arrival ordering', async () => {
const nullArrival = { ...summary, sourceContainerBookingId: '2092873463568597002', containerNo: 'CONT-NULL', plannedArrivalAt: null, planCount: 0, pendingPlanCount: 15 }
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [summary, nullArrival], page: 1, size: 10, total: 2, pages: 1 })
const wrapper = await mountView()
const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))
await containerTab!.trigger('click')
await flushPromises()
expect(listContainerPlanProgress).toHaveBeenLastCalledWith({ page: 1, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined })
expect(wrapper.text()).toContain('柜子(2)')
expect(wrapper.text()).toContain('计划到仓时间')
expect(wrapper.text()).toContain('已有 15 / 缺 0')
expect(wrapper.text()).toContain('已生成10个计划,剩余5个未操作')
expect(wrapper.text()).toContain('未就绪')
expect(wrapper.text()).toContain('存在5票尚未生成有效入库计划')
expect(wrapper.text().indexOf('CONT-15')).toBeLessThan(wrapper.text().indexOf('CONT-NULL'))
})
it('uses independent cabinet pagination and loads a compact long-id detail drawer', async () => {
vi.mocked(listContainerPlanProgress).mockResolvedValue({ records: [summary], page: 1, size: 10, total: 21, pages: 3 })
const wrapper = await mountView()
const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))
await containerTab!.trigger('click')
await flushPromises()
const pageTwo = wrapper.find('button[aria-label="第 2 页"]')
await pageTwo.trigger('click')
await flushPromises()
expect(listContainerPlanProgress).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 10 }))
const viewButtons = wrapper.findAll('button').filter((item) => item.text() === '查看')
const viewButton = viewButtons[viewButtons.length - 1]
await viewButton!.trigger('click')
await flushPromises()
expect(getContainerPlanProgress).toHaveBeenCalledWith(bookingId)
expect(document.body.textContent).toContain('柜级入库计划详情')
expect(document.body.textContent).toContain('LMO-1')
expect(document.body.textContent).toContain('IN-1')
expect(document.body.textContent).toContain('LIP-1')
})
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow(), selectedRow({ id: '2092873463568596995', customerId: '2092618717861048322' })])
await button(wrapper, '转仓')!.trigger('click')
await flushPromises()
expect(listAllPlatformWarehouses).toHaveBeenCalledWith({ warehouseType: 'FBA', status: 'ACTIVE' })
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择目的 FBA 仓')!
expect(warehouseSelect).toBeDefined()
const option = wrapper.findAllComponents({ name: 'ElOption' }).find((item) => item.props('value') === '9007199254740993')!
expect(option.props('label')).toBe('ONT8 · Ontario FBA · CA / Ontario')
warehouseSelect.vm.$emit('update:modelValue', '9007199254740993')
await wrapper.vm.$nextTick()
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认转仓')) as HTMLButtonElement
expect(confirm.disabled).toBe(false)
confirm.click()
await flushPromises()
expect(transferLastMileOrders).toHaveBeenCalledWith({
orderIds: ['2092873463568596994', '2092873463568596995'],
warehouseId: '9007199254740993', warehouseCode: 'ONT8', warehouseName: 'Ontario FBA', remark: undefined,
})
expect(vi.mocked(transferLastMileOrders).mock.calls[0]?.[0].warehouseId).toBeTypeOf('string')
expect(wrapper.html()).not.toContain('请输入目的仓 ID')
})
it('clears all selected warehouse facts when the FBA candidate is cleared', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '转仓')!.trigger('click')
await flushPromises()
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择目的 FBA 仓')!
warehouseSelect.vm.$emit('update:modelValue', '9007199254740993')
await wrapper.vm.$nextTick()
warehouseSelect.vm.$emit('update:modelValue', '')
await wrapper.vm.$nextTick()
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认转仓')) as HTMLButtonElement
expect(confirm.disabled).toBe(true)
expect(transferLastMileOrders).not.toHaveBeenCalled()
})
it('renders an explicit empty state when there are no active FBA candidates', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([])
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '转仓')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('暂无可用 FBA 仓候选')
expect(transferLastMileOrders).not.toHaveBeenCalled()
})
it('keeps candidate load request metadata visible and prevents free-form fallback', async () => {
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new ApiResponseError(
'FBA candidate failed', 'A0503', 200, undefined, 'request-fba', 'trace-fba',
))
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '转仓')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('A0503')
expect(document.body.textContent).toContain('request-fba')
expect(document.body.textContent).toContain('trace-fba')
expect(wrapper.html()).not.toContain('请输入目的仓 ID')
expect(wrapper.html()).not.toContain('请输入目的仓代码')
expect(wrapper.html()).not.toContain('请输入目的仓名称')
expect(transferLastMileOrders).not.toHaveBeenCalled()
})
})
@@ -1,24 +1,26 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Download, Eye } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { convertLastMileOrderToInboundPlan, listLastMileOrders, setLastMileOrderDetained, setLastMileOrderPalletMergeBatch, transferLastMileOrders, downloadLastMileOrders } from '../api/lastMileOrders'
import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { toLastMileOrderListItem } from '../lastMilePresentation'
import type { LastMileOrderListItem } from '../types'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { saveCsv } from '@/modules/overseas-master-data/downloadCsv'
import { saveCsv } from '@/shared/downloadCsv'
const rows = ref<LastMileOrderListItem[]>([])
const access = useAccessPreviewStore()
@@ -28,21 +30,31 @@ const router = useRouter()
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const containerTotal = ref(0)
const containerRows = ref<ContainerPlanProgressSummaryDto[]>([])
const containerDetailOpen = ref(false)
const containerDetailLoading = ref(false)
const containerDetailError = ref<ApiErrorDetails | null>(null)
const containerDetail = ref<ContainerPlanProgressDetailDto | null>(null)
const selectedRows = ref<LastMileOrderListItem[]>([])
const cancellableMergeCount = computed(() => selectedRows.value.filter((row) => row.palletMergeFlag === 'REQUIRED').length)
const actionLoading = ref(false)
const exportLoading = ref(false)
async function exportOrders() { exportLoading.value = true; try { const { createdAt: range, ...rest } = filters.value; saveCsv(await downloadLastMileOrders({ ...rest, createdFrom: Array.isArray(range) && range[0] ? `${String(range[0]).slice(0, 10)}T00:00:00` : undefined, createdTo: Array.isArray(range) && range[1] ? `${String(range[1]).slice(0, 10)}T23:59:59` : undefined }) as Blob, '尾程订单.csv') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
const actionError = ref<ApiErrorDetails | null>(null)
const transferOpen = ref(false)
const detainedOpen = ref(false)
const detainedTarget = ref<LastMileOrderListItem | null>(null)
const detainedRemark = ref('')
const transferWarehouseLoading = ref(false)
const transferWarehouseError = ref<ApiErrorDetails | null>(null)
const transferWarehouseOptions = ref<TransferWarehouseOption[]>([])
const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const statusTab = ref<'全部' | '待分配'>('全部')
const containerConvertOpen = ref(false)
const containerOptions = ref<ContainerPlanProgressSummaryDto[]>([])
const containerOptionsLoading = ref(false)
const containerLoadError = ref<ApiErrorDetails | null>(null)
const selectedContainerBookingId = ref('')
let containerOptionRequest = 0
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const fields = computed<QueryField[]>(() => [
const allFields: QueryField[] = [
{ key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' },
{ key: 'orderType', type: 'select', label: '订单类型', placeholder: '全部类型', options: [
@@ -65,11 +77,45 @@ const fields = computed<QueryField[]>(() => [
{ key: 'abnormal', type: 'select', label: '是否异常', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'detained', type: 'select', label: '是否暂扣', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'createdAt', type: 'daterange', label: '创建时间' },
])
]
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
: allFields)
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
interface TransferWarehouseOption {
warehouseId: string
warehouseCode: string
warehouseName: string
region: string
label: string
}
function warehouseRegion(warehouse: PlatformWarehouseSummaryResource) {
const addressRegion = [warehouse.primaryAddress?.stateProvince, warehouse.primaryAddress?.city]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' / ')
return addressRegion || String(warehouse.addressSummary || warehouse.countryCode || '').trim()
}
function toTransferWarehouseOption(warehouse: PlatformWarehouseSummaryResource): TransferWarehouseOption {
const warehouseId = String(warehouse.id)
const warehouseCode = warehouse.warehouseCode.trim()
const warehouseName = warehouse.warehouseName.trim()
const region = warehouseRegion(warehouse)
return {
warehouseId,
warehouseCode,
warehouseName,
region,
label: [warehouseCode, warehouseName, region].filter(Boolean).join(' · '),
}
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 270, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false },
{ key: 'palletMergeRelation', prop: 'palletMergeRelation', label: '合托关联入仓单', minWidth: 220, hideable: false },
{ key: 'warning', prop: 'warning', label: '预警', width: 90 },
@@ -102,11 +148,55 @@ const columns: DataTableColumn[] = [
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 },
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 },
]
const containerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'containerNo', prop: 'containerNo', label: '柜号', minWidth: 170 },
{ key: 'plannedArrivalAt', prop: 'plannedArrivalAt', label: '计划到仓时间', minWidth: 170, slotName: 'plannedArrivalAt' },
{ key: 'totalOrderCount', prop: 'totalOrderCount', label: '订单总数', width: 100, align: 'right' },
{ key: 'inboundCodes', label: '入仓码', minWidth: 170, slotName: 'inboundCodes' },
{ key: 'planProgress', label: '入库计划进度', minWidth: 270, slotName: 'planProgress' },
{ key: 'readiness', label: '整柜就绪状态', minWidth: 240, slotName: 'readiness' },
]
const containerDetailColumns: DataTableColumn[] = [
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'orderStatus', prop: 'orderStatus', label: '订单状态', width: 130 },
{ key: 'planNo', prop: 'planNo', label: '独立入库计划', minWidth: 170 },
{ key: 'planStatus', prop: 'planStatus', label: '计划状态', width: 130 },
{ key: 'plannedArrivalAt', prop: 'plannedArrivalAt', label: '计划到仓时间', minWidth: 170, slotName: 'plannedArrivalAt' },
]
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 19) : '—'
}
function canGenerateContainerPlans(container: ContainerPlanProgressSummaryDto) {
return Boolean(container.containerNo) && container.missingInboundCodeCount === 0 && container.pendingPlanCount > 0
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
loading.value = true
loadError.value = null
if (statusTab.value === '柜子') {
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
try {
const result = await listContainerPlanProgress({
page: page.value,
size: pageSize.value,
containerNo: String(filters.value.containerNo || '').trim() || undefined,
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
})
containerRows.value = result.records
containerTotal.value = result.total
} catch (error) {
containerRows.value = []
containerTotal.value = 0
loadError.value = getApiErrorDetails(error, '柜级入库计划进度加载失败,请重试')
} finally {
loading.value = false
}
return
}
try {
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined
@@ -141,79 +231,142 @@ 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 changeStatusTab() { page.value = 1; void load() }
function changeStatusTab() {
selectedRows.value = []
page.value = 1
void load()
}
async function openContainerDetail(row: ContainerPlanProgressSummaryDto) {
containerDetailOpen.value = true
containerDetailLoading.value = true
containerDetailError.value = null
containerDetail.value = null
try {
containerDetail.value = await getContainerPlanProgress(row.sourceContainerBookingId)
} catch (error) {
containerDetailError.value = getApiErrorDetails(error, '柜级详情加载失败,请重试')
} finally {
containerDetailLoading.value = false
}
}
function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] }
async function loadTransferWarehouseOptions() {
transferWarehouseLoading.value = true
transferWarehouseError.value = null
transferWarehouseOptions.value = []
try {
const warehouses = await listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })
transferWarehouseOptions.value = warehouses.map(toTransferWarehouseOption)
} catch (error) {
transferWarehouseError.value = getApiErrorDetails(error, 'FBA 仓候选加载失败,请重试')
} finally {
transferWarehouseLoading.value = false
}
}
function selectTransferWarehouse(warehouseId: string) {
const selected = transferWarehouseOptions.value.find((warehouse) => warehouse.warehouseId === String(warehouseId || ''))
Object.assign(transferForm, selected
? { warehouseId: selected.warehouseId, warehouseCode: selected.warehouseCode, warehouseName: selected.warehouseName }
: { warehouseId: '', warehouseCode: '', warehouseName: '' })
}
function openTransfer() {
if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return }
Object.assign(transferForm, { warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
transferWarehouseOptions.value = []
transferWarehouseError.value = null
actionError.value = null
transferOpen.value = true
}
function openDetained(row: LastMileOrderListItem) {
detainedTarget.value = row
detainedRemark.value = ''
actionError.value = null
detainedOpen.value = true
}
async function submitDetained() {
const target = detainedTarget.value
if (!target) return
if (!detainedRemark.value.trim()) { ElMessage.warning('请填写操作备注'); return }
actionLoading.value = true; actionError.value = null
try {
await setLastMileOrderDetained(target.id, { detained: !target.detained, remark: detainedRemark.value.trim() })
detainedOpen.value = false; detainedTarget.value = null
ElMessage.success(target.detained ? '已解除暂扣,库存已转为可用' : '已标记暂扣,后续入库将进入暂扣库存')
await load()
} catch (error) { actionError.value = getApiErrorDetails(error, target.detained ? '解除暂扣失败,请重试' : '暂扣失败,请重试') }
finally { actionLoading.value = false }
}
async function batchPalletMerge() {
const targets = selectedRows.value.filter((row) => row.palletMergeFlag !== 'MERGED')
if (!targets.length) { ElMessage.warning('请选择至少 1 条未完成合托的尾程订单'); return }
try {
await ElMessageBox.confirm(`确定为选中的 ${targets.length} 条尾程订单标记合托吗?PDA 扫描对应箱唛时将显示合托提示。`, '批量合托标记', { type: 'warning', confirmButtonText: '确认标记', cancelButtonText: '返回' })
} catch { return }
actionLoading.value = true; actionError.value = null
try {
await setLastMileOrderPalletMergeBatch(targets.map((row) => row.id), true)
selectedRows.value = []; ElMessage.success(`已标记 ${targets.length} 条订单合托`); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '批量合托标记失败,请重试') }
finally { actionLoading.value = false }
}
async function cancelBatchPalletMerge() {
const targets = selectedRows.value.filter((row) => row.palletMergeFlag === 'REQUIRED')
if (!targets.length) { ElMessage.warning('请选择至少 1 条待合托订单'); return }
try {
await ElMessageBox.confirm(`确定取消选中的 ${targets.length} 条订单合托标记吗?`, '取消合托标记', { type: 'warning', confirmButtonText: '确认取消', cancelButtonText: '返回' })
} catch { return }
actionLoading.value = true; actionError.value = null
try {
await setLastMileOrderPalletMergeBatch(targets.map((row) => row.id), false)
selectedRows.value = []; ElMessage.success(`已取消 ${targets.length} 条订单合托标记`); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '取消合托标记失败,请重试') }
finally { actionLoading.value = false }
void loadTransferWarehouseOptions()
}
async function submitTransfer() {
if (!transferForm.warehouseId.trim() || !transferForm.warehouseCode.trim() || !transferForm.warehouseName.trim()) {
ElMessage.warning('请填写目的仓 ID、代码和名称')
const selectedWarehouse = transferWarehouseOptions.value.find((warehouse) => warehouse.warehouseId === transferForm.warehouseId)
if (!selectedWarehouse) {
ElMessage.warning('请选择目的 FBA 仓')
return
}
actionLoading.value = true; actionError.value = null
try {
await transferLastMileOrders({ orderIds: selectedRows.value.map((row) => row.id), warehouseId: transferForm.warehouseId.trim(), warehouseCode: transferForm.warehouseCode.trim(), warehouseName: transferForm.warehouseName.trim(), remark: transferForm.remark.trim() || undefined })
await transferLastMileOrders({
orderIds: selectedRows.value.map((row) => String(row.id)),
warehouseId: selectedWarehouse.warehouseId,
warehouseCode: selectedWarehouse.warehouseCode,
warehouseName: selectedWarehouse.warehouseName,
remark: transferForm.remark.trim() || undefined,
})
transferOpen.value = false; selectedRows.value = []; ElMessage.success('转仓成功'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') }
finally { actionLoading.value = false }
}
async function convertInboundPlan() {
if (selectedRows.value.length !== 1) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
actionLoading.value = true; actionError.value = null
const selected = selectedRows.value[0]
const customerId = typeof selected.customerId === 'string' ? selected.customerId.trim() : ''
if (!customerId) {
actionError.value = { message: '所选尾程订单缺少客户标识,无法安全生成入库计划' }
return
}
actionLoading.value = true
actionError.value = null
try {
await convertLastMileOrderToInboundPlan({ orderId: selectedRows.value[0].id })
selectedRows.value = []; ElMessage.success('入库计划生成成功'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') }
finally { actionLoading.value = false }
await convertLastMileOrderToInboundPlan({ orderId: String(selected.id), customerId })
selectedRows.value = []
ElMessage.success('入库计划生成成功')
await load()
} catch (error) {
actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试')
} finally {
actionLoading.value = false
}
}
async function loadContainerOptions(containerNo = '') {
const request = ++containerOptionRequest
containerOptionsLoading.value = true
containerLoadError.value = null
try {
const result = await listContainerPlanProgress({
page: 1,
size: 50,
containerNo: containerNo.trim() || undefined,
})
if (request !== containerOptionRequest) return
containerOptions.value = result.records
} catch (error) {
if (request !== containerOptionRequest) return
containerOptions.value = []
containerLoadError.value = getApiErrorDetails(error, '待分配柜列表加载失败,请重试')
} finally {
if (request === containerOptionRequest) containerOptionsLoading.value = false
}
}
function openContainerConversion() {
selectedContainerBookingId.value = ''
containerOptions.value = []
containerLoadError.value = null
actionError.value = null
containerConvertOpen.value = true
void loadContainerOptions()
}
async function convertContainerInboundPlans() {
const container = selectedContainer.value
if (!container?.sourceContainerBookingId) { ElMessage.warning('请选择一个待分配柜'); return }
if (!canGenerateContainerPlans(container)) {
actionError.value = { message: container.blockingReason || '所选柜当前不满足生成资格' }
return
}
actionLoading.value = true
actionError.value = null
try {
const plans = await convertLastMileContainerToInboundPlans({ sourceContainerBookingId: container.sourceContainerBookingId })
containerConvertOpen.value = false
selectedContainerBookingId.value = ''
ElMessage.success(`已按柜生成 ${plans.length} 个独立入库计划`)
await load()
} catch (error) {
actionError.value = getApiErrorDetails(error, '生成整柜入库计划失败,请重试')
} finally {
actionLoading.value = false
}
}
function openDetail(source: object) {
@@ -221,14 +374,7 @@ function openDetail(source: object) {
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
}
onMounted(() => { void load(); void loadWarehouses() })
async function loadWarehouses() {
try {
warehouses.value = await listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })
} catch {
warehouses.value = []
}
}
onMounted(() => { void load() })
</script>
<template>
@@ -238,6 +384,7 @@ async function loadWarehouses() {
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
<el-radio-button value="全部">全部</el-radio-button>
<el-radio-button value="待分配">待分配</el-radio-button>
<el-radio-button value="柜子">柜子({{ containerTotal }})</el-radio-button>
</el-radio-group>
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="search" @reset="reset" />
</section>
@@ -247,26 +394,77 @@ async function loadWarehouses() {
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" :row-class-name="({ row }: { row: LastMileOrderListItem }) => row.detained ? 'is-detained-row' : ''" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" type="primary" plain :disabled="!selectedRows.length || actionLoading" @click="batchPalletMerge">合托标记</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" type="warning" plain :disabled="!cancellableMergeCount || actionLoading" @click="cancelBatchPalletMerge">取消合托</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" size="small" plain :type="(row as LastMileOrderListItem).detained ? 'success' : 'danger'" :disabled="actionLoading" @click="openDetained(row as LastMileOrderListItem)">{{ (row as LastMileOrderListItem).detained ? '解除暂扣' : '暂扣' }}</el-button></template>
<LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact>
<template #details><ApiErrorMeta :details="actionError" /></template>
</LoadErrorState>
<section v-if="!loadError && statusTab === '柜子'" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="containerRows" :columns="containerColumns" :loading="loading" :total="containerTotal" preference-key="overseas-last-mile-container-progress" row-key="sourceContainerBookingId" empty-text="暂无柜级入库计划进度" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openContainerDetail(row as ContainerPlanProgressSummaryDto)">查看</el-button></template>
<template #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressSummaryDto).plannedArrivalAt) }}</template>
<template #inboundCodes="{ row }">已有 {{ (row as ContainerPlanProgressSummaryDto).inboundCodeCount }} / 缺 {{ (row as ContainerPlanProgressSummaryDto).missingInboundCodeCount }}</template>
<template #planProgress="{ row }">已生成{{ (row as ContainerPlanProgressSummaryDto).planCount }}个计划,剩余{{ (row as ContainerPlanProgressSummaryDto).pendingPlanCount }}个未操作</template>
<template #readiness="{ row }"><el-tag :type="(row as ContainerPlanProgressSummaryDto).allPlansReady ? 'success' : 'warning'" effect="plain">{{ (row as ContainerPlanProgressSummaryDto).allPlansReady ? '整柜已就绪' : '未就绪' }}</el-tag><span v-if="!(row as ContainerPlanProgressSummaryDto).allPlansReady" class="readiness-reason">{{ (row as ContainerPlanProgressSummaryDto).blockingReason || '尚未满足全部独立计划条件,不可视为 PDA 可作业' }}</span></template>
<template #pagination-left>共 {{ containerTotal }} 个柜</template>
</DataTable>
</section>
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" /></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog v-model="transferOpen" title="转仓" confirm-text="确认转仓" :confirm-loading="actionLoading" @confirm="submitTransfer">
<el-form label-position="top">
<el-form-item label="FBA 目的仓" required><el-select v-model="transferForm.warehouseId" filterable clearable placeholder="请选择头程 FBA 目的仓" style="width:100%" @change="(id: string) => { const w = warehouses.find((item) => String(item.id) === id); transferForm.warehouseCode = w?.warehouseCode || ''; transferForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<AppDialog v-model="transferOpen" title="转仓" confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
<LoadErrorState v-if="transferWarehouseError" title="FBA 仓候选加载失败" :message="transferWarehouseError.message" compact @retry="loadTransferWarehouseOptions">
<template #details><ApiErrorMeta :details="transferWarehouseError" /></template>
</LoadErrorState>
<el-form v-else label-position="top">
<el-form-item label="目的 FBA 仓" required>
<el-select v-model="transferForm.warehouseId" filterable clearable placeholder="请选择目的 FBA 仓" @update:model-value="selectTransferWarehouse">
<el-option v-for="warehouse in transferWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" />
</el-select>
</el-form-item>
<el-alert v-if="!transferWarehouseLoading && !transferWarehouseOptions.length" title="暂无可用 FBA 仓候选" type="info" :closable="false" show-icon />
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="detainedOpen" :title="detainedTarget?.detained ? '解除暂扣' : '暂扣订单'" :confirm-text="detainedTarget?.detained ? '确认解除' : '确认暂扣'" :confirm-loading="actionLoading" @confirm="submitDetained">
<el-form label-position="top"><el-form-item label="操作备注" required><el-input v-model="detainedRemark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写暂扣或解除原因" /></el-form-item></el-form>
<AppDialog v-model="containerConvertOpen" title="按柜批量生成入库计划" width="640px" confirm-text="确认按柜生成" :loading="containerOptionsLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedContainer || !canGenerateContainerPlans(selectedContainer)" @confirm="convertContainerInboundPlans">
<p class="container-convert-hint">将为所选柜内所有待分配尾程订单分别生成独立入库计划。</p>
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
</LoadErrorState>
<el-form v-else label-position="top">
<el-form-item label="选择待分配柜" required>
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" placeholder="输入柜号搜索并选择">
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :disabled="!canGenerateContainerPlans(container)" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票${canGenerateContainerPlans(container) ? '' : ` · ${container.blockingReason || '不可生成'}`}`" />
</el-select>
</el-form-item>
<el-alert v-if="selectedContainer" :title="canGenerateContainerPlans(selectedContainer) ? `确认后将为该柜剩余 ${selectedContainer.pendingPlanCount} 票分别生成独立入库计划` : selectedContainer.blockingReason || '所选柜不可生成'" :type="canGenerateContainerPlans(selectedContainer) ? 'info' : 'error'" :closable="false" show-icon />
</el-form>
</AppDialog>
<AppDrawer v-model="containerDetailOpen" title="柜级入库计划详情" size="min(1040px, 96vw)" :loading="containerDetailLoading" :show-footer="false">
<LoadErrorState v-if="containerDetailError" title="柜级详情加载失败" :message="containerDetailError.message" compact>
<template #details><ApiErrorMeta :details="containerDetailError" /></template>
</LoadErrorState>
<template v-else-if="containerDetail">
<div class="container-detail-summary">
<span>柜号:{{ containerDetail.summary.containerNo || '—' }}</span>
<span>计划到仓:{{ formatDateTime(containerDetail.summary.plannedArrivalAt) }}</span>
<span>订单:{{ containerDetail.summary.totalOrderCount }}</span>
<span>入仓码:{{ containerDetail.summary.inboundCodeCount }},缺 {{ containerDetail.summary.missingInboundCodeCount }}</span>
<span>计划:{{ containerDetail.summary.planCount }},未操作 {{ containerDetail.summary.pendingPlanCount }}</span>
</div>
<DataTable :data="containerDetail.orders" :columns="containerDetailColumns" :loading="containerDetailLoading" preference-key="overseas-last-mile-container-detail" row-key="orderId" :total="undefined" empty-text="该柜暂无尾程订单">
<template #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressDetailDto['orders'][number]).plannedArrivalAt) }}</template>
</DataTable>
</template>
</AppDrawer>
</template>
<style scoped>
.last-mile-toolbar { padding: 14px 16px 0; }
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
.table-panel :deep(.el-table .is-detained-row > td.el-table__cell) { background: #fff1f0 !important; color: #b42318; }
.table-panel :deep(.el-table .is-detained-row:hover > td.el-table__cell) { background: #ffe4e1 !important; }
.container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; }
.readiness-reason { margin-left: 8px; color: var(--cc-muted); font-size: 12px; }
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }
</style>
@@ -37,5 +37,5 @@ onMounted(load)
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form label-position="top"><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" filterable placeholder="请选择有可用库存的托盘"><el-option v-for="item in positionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || item.position.id} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog>
<AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form label-position="top"><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" filterable placeholder="请选择有可用库存的托盘"><el-option v-for="item in positionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || '托盘号待补充'} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog>
</template>
@@ -57,12 +57,8 @@ describe('PalletsView', () => {
await wrapper.findAll('button').find((button) => button.text().includes('新增托盘'))!.trigger('click')
await flushPromises()
const warehouse = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择海外仓')!
const fbaWarehouse = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择所属 FBA 仓')!
const palletCode = wrapper.findAllComponents({ name: 'ElInput' }).find((item) => item.props('placeholder') === '请输入托盘编码')!
warehouse.vm.$emit('update:modelValue', '200')
fbaWarehouse.vm.$emit('update:modelValue', 'ABE3')
palletCode.vm.$emit('update:modelValue', 'PLT-LAX-ABE3')
const view = wrapper.vm as unknown as { form: { warehouseId: string; fbaWarehouseCode: string; code: string } }
Object.assign(view.form, { warehouseId: '200', fbaWarehouseCode: 'ABE3', code: 'PLT-LAX-ABE3' })
await flushPromises()
wrapper.findComponent(AppDialog).vm.$emit('confirm')
await flushPromises()
@@ -25,7 +25,7 @@ function status(value: unknown) {
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
return labels[String(value || '')] || text(value)
}
const referenceValue = computed(() => text(detail.value?.application.relabelNo || orderId.value))
const referenceValue = computed(() => text(detail.value?.application.relabelNo || '换标单号待补充'))
const fieldValues = computed<Record<string, string | number>>(() => {
const value = detail.value
const items = value?.items || []
@@ -305,17 +305,10 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(detail).not.toContain('ElMessage.success')
})
it('shows the transfer route label on PDA pallet picking rows', () => {
const detail = source('DispatchDetailView.vue')
expect(detail).toContain('label="转仓提示"')
expect(detail).toContain('row.warehouseRouteLabel')
expect(detail).toContain('type="warning"')
})
it('preserves last-mile status tabs and read-only outbound association columns without links', () => {
const value = source('LastMileOrdersView.vue')
expect(value).toContain("listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })")
expect(value).toContain('请选择头程 FBA 目的仓')
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>')
@@ -363,8 +356,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
'actions', 'sku', 'productName', 'englishName', 'category', 'length', 'width', 'height', 'weight',
])
expect(columnKeys('InventoryTransactionsView.vue')).toEqual([
'occurredAt', 'owner', 'warehouse', 'sku', 'productName', 'businessType', 'businessOrderNo',
'inboundOrderNo', 'outboundOrderNo', 'inboundQuantity', 'outboundQuantity', 'balance', 'operator', 'remark',
'actions', 'occurredAt', 'owner', 'warehouse', 'sku', 'productName', 'businessType', 'businessOrderNo',
'inboundCode', 'inboundOrderNo', 'outboundOrderNo', 'inboundQuantity', 'outboundQuantity', 'balance', 'operator', 'remark',
])
})
})
@@ -1,17 +1,21 @@
import ElementPlus from 'element-plus'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listPlatformTenants } from '@/modules/iam'
import { getInboundPlan } from '../api/inboundPlans'
import { listInventoryLedger } from '../api/inventory'
import { getLastMileOrder } from '../api/lastMileOrders'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
import InventoryTransactionsView from './InventoryTransactionsView.vue'
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn() }))
vi.mock('../api/inventory', () => ({ listInventoryLedger: vi.fn() }))
vi.mock('../api/lastMileOrders', () => ({ getLastMileOrder: vi.fn(), createLastMileOrderFee: vi.fn() }))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals())
@@ -21,8 +25,10 @@ describe('overseas warehouse mounted real API pages', () => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ONT8', destinationWarehouseName: '洛杉矶仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', destinationWarehouseCode: 'LAX', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', customerName: '客户甲', destinationWarehouseCode: 'LAX', inboundCode: 'NYEXX2608300001', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getLastMileOrder).mockReset().mockResolvedValue({ order: { id: '9007199254740999', customerId: '10001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', currentStatus: 'PENDING_ASSIGNMENT' }, cargo: [], statusHistory: [], fees: [], operationHistory: [] })
})
it('mounts inbound detail with a string-safe source order id', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('9007199254740995'); expect(wrapper.text()).toContain('洛杉矶仓') })
it('mounts inventory ledger as read-only facts', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('不提供出库单跳转') })
it('mounts inventory ledger as read-only business facts without exposing customer ids', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('NYEXX2608300001'); expect(wrapper.text()).not.toContain('10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('不提供出库单跳转') })
it('renders the backend owner and complete short order number in last-mile detail', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001') })
})
+7 -6
View File
@@ -6,6 +6,7 @@
export interface LastMileOrderListItem {
id: string
customerId: string
sourceContainerBookingId: string
orderNo: string
warning: string
orderType: string
@@ -19,12 +20,11 @@ export interface LastMileOrderListItem {
containerStatus: string
abnormal: string
held: string
detained: boolean
pallets: number
cartons: number
volume: number
quantity: number
volumeWeight: number
pallets: number | '—'
cartons: number | '—'
volume: number | '—'
quantity: number | '—'
volumeWeight: number | '—'
portEta: string
portAta: string
warehouseEta: string
@@ -179,6 +179,7 @@ export interface InventoryTransactionListItem {
productName: string
businessType: string
businessOrderNo: string
inboundCode: string
inboundOrderNo: string
/** Read-only inventory fact; it is not an outbound-workflow link. */
outboundOrderNo: string