Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into dev
This commit is contained in:
@@ -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') })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user